@operstack/audit 0.30.0 → 0.32.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/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.30.0",
3
+ "version": "0.32.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,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",
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
- for (const u of pool.slice(0, pages)) {
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
- sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, 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),
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
  }
@@ -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 listRu = (a) => a.length > 1 ? `${a.slice(0, -1).join(', ')} и ${a[a.length - 1]}` : a[0] || '';
117
- const listEn = (a) => a.length > 1 ? `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}` : a[0] || '';
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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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: 'Что нашли',
@@ -171,7 +184,7 @@ function css() {
171
184
  :root { --ink:#14181c; --ink-2:#3b474d; --muted:#5c6b6f; --rule:#d8dedb; --paper:#fff; --panel:#f3f5f6; --accent:#0b7a75; --accent-soft:#dcefed; --gold:#8a6b38; --gold-soft:#f5efe3; --danger:#b3261e; --warn:#9a6700; --ok:#1f7a3d; --pad:16mm; }
172
185
  * { box-sizing: border-box; margin: 0; padding: 0; }
173
186
  body { font-family: "IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); background: #e9ecec; font-size: 10.2pt; line-height: 1.5; }
174
- .page { width: 210mm; min-height: 297mm; margin: 0 auto 8mm; background: var(--paper); padding: var(--pad) var(--pad) 22mm; position: relative; page-break-after: always; }
187
+ .page { width: 210mm; min-height: 297mm; margin: 0 auto 8mm; background: var(--paper); padding: var(--pad) var(--pad) 20mm; position: relative; page-break-after: always; }
175
188
  .page:last-child { page-break-after: auto; }
176
189
  h1, h2, h3 { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-weight: 600; color: var(--ink); line-height: 1.15; }
177
190
  h2 { font-size: 19pt; margin-bottom: 4mm; padding-bottom: 2mm; border-bottom: 2px solid var(--accent-soft); }
@@ -186,6 +199,39 @@ function css() {
186
199
  .cover h1 { font-size: 34pt; margin: 2mm 0 4mm; }
187
200
  .cover-sub { font-size: 13pt; color: var(--ink-2); max-width: 85%; margin-bottom: 10mm; }
188
201
  .cover-url { display: inline-block; background: var(--accent-soft); color: var(--accent); padding: 2.5mm 4.5mm; border-radius: 4px; font-weight: 600; font-size: 11pt; }
202
+ /* Шапка первого листа вместо обложки: те же поля, но одной строкой и мелким кеглем. */
203
+ .masthead { border-top: 3px solid var(--accent); padding-top: 3.5mm; margin-bottom: 3.5mm; }
204
+ .masthead .cover-logo { max-height: 9mm; margin-bottom: 2.5mm; }
205
+ .mast-eyebrow { font-size: 7.5pt; letter-spacing: .12em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin: 0 0 1.5mm; }
206
+ .mast-title { font-family: "Fraunces", Georgia, serif; font-size: 20pt; line-height: 1.08; margin: 0 0 2mm; font-weight: 600; }
207
+ .mast-sub { font-size: 9pt; color: var(--ink-2); line-height: 1.45; margin: 0 0 2.5mm; max-width: 150mm; }
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; }
209
+ .mast-meta dt { font-size: 6.8pt; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 0 0 .6mm; }
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; }
234
+ .tool-line { font-size: 7.5pt; color: var(--muted); line-height: 1.4; margin: 4mm 0 0; }
189
235
  .cover-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; margin-top: 14mm; padding-top: 6mm; border-top: 1px solid var(--rule); }
190
236
  .cover-meta dt { font-size: 7.5pt; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 1mm; }
191
237
  .cover-meta dd { font-size: 11pt; font-weight: 600; }
@@ -202,7 +248,7 @@ function css() {
202
248
  * Сводка: кольцо, пилюли, полосы. Те же пороги цвета, что на странице результата: 80 и 45.
203
249
  * Печать на бумагу, поэтому все размеры в миллиметрах и пунктах, а не в пикселях.
204
250
  */
205
- .overall { display: flex; align-items: center; gap: 7mm; border: 1px solid var(--rule); border-radius: 5px; padding: 4mm 6mm; background: #fafbfb; margin: 5mm 0 4mm; }
251
+ .overall { display: flex; align-items: center; gap: 7mm; border: 1px solid var(--rule); border-radius: 5px; padding: 3.5mm 6mm; background: #fafbfb; margin: 4mm 0 3mm; }
206
252
  .ring { flex: none; color: var(--ok); }
207
253
  .ring.ring-fair { color: #d9a030; } .ring.ring-poor { color: #d2694f; } .ring.ring-na { color: var(--muted); }
208
254
  .ring-num { font-family: "Fraunces", Georgia, serif; font-size: 42px; font-weight: 600; fill: var(--ink); }
@@ -217,7 +263,7 @@ function css() {
217
263
  .overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 14pt; font-weight: 600; margin: 0 0 1.5mm; }
218
264
  .overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; margin: 0; }
219
265
 
220
- h3.engines-head, h3.areas-head { font-size: 11pt; margin: 6mm 0 2.5mm; }
266
+ h3.engines-head, h3.areas-head { font-size: 11pt; margin: 4mm 0 2mm; }
221
267
  .engines { display: grid; grid-template-columns: repeat(5, 1fr); gap: 2.2mm; }
222
268
  .eng { border: 1px solid var(--rule); border-radius: 4px; padding: 2.4mm 2.6mm; background: #fafbfb; }
223
269
  .eng p { margin: 0; }
@@ -234,7 +280,7 @@ function css() {
234
280
  .eng-na .pbar i, .ar-na .pbar i { background: var(--muted); }
235
281
 
236
282
  .areas-bars { margin-bottom: 2mm; }
237
- .ar { padding: 2mm 0; border-top: 1px solid var(--rule); }
283
+ .ar { padding: 1.6mm 0; border-top: 1px solid var(--rule); }
238
284
  .ar:first-child { border-top: 0; padding-top: 0.5mm; }
239
285
  .ar-h { display: grid; grid-template-columns: 1fr auto auto; gap: 3mm; align-items: baseline; margin-bottom: 1.4mm; }
240
286
  .ar-n { font-size: 9pt; font-weight: 600; }
@@ -263,6 +309,11 @@ function css() {
263
309
  .kpi { flex: 1; background: var(--panel); border-radius: 4px; padding: 3mm 4mm; }
264
310
  .kpi-value { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-size: 17pt; font-weight: 600; color: var(--ink); line-height: 1.1; }
265
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; }
266
317
  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); }
267
318
  .cards { display: grid; gap: 3mm; margin: 4mm 0; }
268
319
  .card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
@@ -317,7 +368,7 @@ export function toHtml(audit, brand = null) {
317
368
  const tone = (pct) => (pct === null ? 'na' : pct >= 80 ? 'good' : pct >= 45 ? 'fair' : 'poor');
318
369
  const RING_R = 58;
319
370
  const RING_C = 2 * Math.PI * RING_R;
320
- const ring = (score) => `<svg class="ring ring-${tone(score)}" viewBox="0 0 148 148" width="118" height="118" role="img" aria-label="${score} ${esc(L.ofHundred || 'of 100')}">
371
+ const ring = (score) => `<svg class="ring ring-${tone(score)}" viewBox="0 0 148 148" width="104" height="104" role="img" aria-label="${score} ${esc(L.ofHundred || 'of 100')}">
321
372
  <circle cx="74" cy="74" r="${RING_R}" fill="none" stroke="#ECE9E2" stroke-width="12"/>
322
373
  <circle cx="74" cy="74" r="${RING_R}" fill="none" stroke="currentColor" stroke-width="12" stroke-linecap="round"
323
374
  stroke-dasharray="${RING_C.toFixed(1)}" stroke-dashoffset="${(RING_C * (1 - score / 100)).toFixed(1)}" transform="rotate(-90 74 74)"/>
@@ -358,13 +409,23 @@ export function toHtml(audit, brand = null) {
358
409
  const pages = [];
359
410
  const logo = brand && brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : '';
360
411
  const preparedBy = (brand && brand.preparedBy) || a.client.preparedBy || 'OperStack';
361
- const toolLine = !brand || brand.showToolLine !== false
362
- ? `<div class="cover-foot">${esc(L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</div>`
363
- : `<div class="cover-foot">${esc(L.reproducible)}</div>`;
364
- pages.push(`<div class="page"><div class="cover"><div><div class="cover-top">${logo}<div class="eyebrow">${L.eyebrowAudit}</div><h1>${L.coverTitle}</h1><p class="cover-sub">${t(a.client.subject)}</p><span class="cover-url">${esc(a.meta.host)}</span></div>
365
- <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>
366
- ${toolLine}</div></div>`);
367
- n++;
412
+ /*
413
+ * Обложки отдельным листом больше нет: заголовок уменьшен и стоит шапкой первого листа.
414
+ *
415
+ * Лист, на котором только название и четыре поля, в отчёте на двенадцать страниц никто не
416
+ * читает. Замерено перед правкой: содержимое ровно влезает в лист, перелива ноль. Строка про
417
+ * предмет обрезается по длине, иначе длинное описание чужого сайта вытолкнет низ листа на
418
+ * следующую страницу, и мы вернёмся к тому, от чего уходили.
419
+ */
420
+ const subjectText = t(a.client.subject);
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
+ })();
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>`;
368
429
  /*
369
430
  * Сводка занимает ДВА листа намеренно, а не один с переливом.
370
431
  *
@@ -373,9 +434,47 @@ export function toHtml(audit, brand = null) {
373
434
  * выглядело это как недоделанная работа. Теперь первый лист про балл и из чего он сложился,
374
435
  * второй про проверки самого отчёта и что делать. Оба полные.
375
436
  */
376
- 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}${footer(a, n++, preparedBy)}</div>`);
437
+ pages.push(`<div class="page">${masthead}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}${footer(a, n++, preparedBy)}</div>`);
377
438
  pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secondMeasure}</h2><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>`);
378
- 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 class="pages"><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>${(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.h2Count ?? '·'}</td><td>${p.tables ?? '·'}</td><td>${p.linkCount ?? '·'}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
439
+ /*
440
+ * Обзор: таблица прочитанных страниц не влезала на лист и переливалась. Режем её по счёту
441
+ * строк, как техническую. Первый лист несёт параметры сайта и первые строки, дальше только
442
+ * продолжение таблицы со своей шапкой.
443
+ */
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;
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>`;
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>`;
469
+ const FIRST_PAGE_ROWS = 7;
470
+ const NEXT_PAGE_ROWS = 16;
471
+ const firstRows = sampleRows.slice(0, FIRST_PAGE_ROWS);
472
+ const restRows = sampleRows.slice(FIRST_PAGE_ROWS);
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>`);
474
+ for (let i = 0; i < restRows.length; i += NEXT_PAGE_ROWS) {
475
+ const chunk = restRows.slice(i, i + NEXT_PAGE_ROWS);
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>`);
477
+ }
379
478
  const cardBody = (c) => [
380
479
  c.text ? `<p>${t(c.text)}</p>` : '',
381
480
  c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
@@ -417,14 +516,145 @@ export function toHtml(audit, brand = null) {
417
516
  });
418
517
  pages.push(`<div class="page">${header(a, L.secContent, L)}<div class="eyebrow">${L.contentEyebrow}</div><h2>${L.contentTitle}</h2><div class="two-col"><div><h3>${L.strengths}</h3>${list(a.content.strengths)}</div><div><h3>${L.weaknesses}</h3>${list(a.content.weaknesses)}</div></div><h3>${L.gaps}</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
419
518
  const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment.charAt(0).toUpperCase() + c.comment.slice(1))}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
420
- pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">Whether the site's answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.</p><div class="two-col"><div><h3>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div><h2>${L.geoTitle}</h2><table><tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++, preparedBy)}</div>`);
421
- const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('')}</table>` : '');
422
519
  const sources = [...(a.sources || []), ...(a.demand && Array.isArray(a.demand.queries) ? [demandSourceRow(a.demand, a.meta?.lang === 'ru' ? 'ru' : 'en')] : [])];
423
- 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><div class="eyebrow" style="margin-top:8mm">${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><div class="eyebrow" style="margin-top:8mm">${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((s) => `<tr><td>${t(s.name)}</td><td>${t(s.access)}</td><td>${t(s.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}${footer(a, n++, preparedBy)}</div>`);
424
- const phases = (a.roadmap || []).map((p) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(p.badge)}</span><span class="phase-title">${t(p.title)}</span></div>${olist(p.items)}</div>`).join('');
425
- 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>${footer(a, n++, preparedBy)}</div>`);
520
+ const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('')}</table>` : '');
521
+ /*
522
+ * AEO и GEO стояли на одном листе: два заголовка, два списка, две врезки и таблица сигналов.
523
+ * Лист переливал почти на восемьсот пикселей. Теперь по разделу на лист.
524
+ */
525
+ pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">${esc(L.aeoLead)}</p><div class="two-col"><div><h3>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div>${footer(a, n++, preparedBy)}</div>`);
526
+ /*
527
+ * Таблица сигналов GEO выросла до двадцати пяти строк и на лист не влезает. Режем её, как и
528
+ * остальные длинные таблицы: по счёту строк, с продолжением под своим номером.
529
+ */
530
+ const geoRowList = [...checksBy('geo'), ...checksBy('overview')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`);
531
+ const geoHead = `<tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>`;
532
+ const GEO_ROWS_PER_PAGE = 15;
533
+ const geoChunks = [];
534
+ for (let i = 0; i < geoRowList.length; i += GEO_ROWS_PER_PAGE) geoChunks.push(geoRowList.slice(i, i + GEO_ROWS_PER_PAGE));
535
+ (geoChunks.length ? geoChunks : [[]]).forEach((chunk, i, all) => {
536
+ const title = all.length > 1 ? `${L.geoTitle} (${i + 1}/${all.length})` : L.geoTitle;
537
+ const tail = i === all.length - 1 ? `<div class="callout"><p>${t(a.geo.callout)}</p></div>` : '';
538
+ pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${esc(title)}</h2><table>${geoHead}${chunk.join('')}</table>${tail}${footer(a, n++, preparedBy)}</div>`);
539
+ });
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>`);
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>`);
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
+
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('');
633
+ /*
634
+ * Последний лист несёт и дорожную карту, и источники с границами замера. Оба раздела короткие,
635
+ * по отдельности каждый занимал меньше половины листа, и владелец просил ровно двенадцать.
636
+ * Проверено замером: вместе влезают без перелива.
637
+ */
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>`);
426
639
  // Карта спроса идёт приложением после дорожной карты: она не входит в баллы, и об этом
427
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
+
428
658
  if (a.demand && Array.isArray(a.demand.queries)) {
429
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>`);
430
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 && /&quot;\@type&quot;: &quot;Organization&quot;/.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
+ }