@operstack/audit 0.32.0 → 0.33.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 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 --limit 3
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 what the free tier hands out; the full list ships with the paid audit. `renderFixChecklist(plan, { withPrompts: true })` embeds the same tasks in the executor checklist.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.32.0",
3
+ "version": "0.33.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 && node test-polite.mjs && node src/test-handover.mjs",
32
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs",
33
33
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
34
  "verify:live": "node src/verify-live.mjs"
35
35
  },
package/src/collect.mjs CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
10
10
  import { parse } from 'node-html-parser';
11
11
  import { localiseChecks } from './i18n.mjs';
12
- import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
12
+ import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows } from './visibility.mjs';
13
13
 
14
14
  /** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
15
15
  * в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
@@ -104,18 +104,47 @@ export function crawlDelayFrom(robotsText, capMs = 3000) {
104
104
  return { ms: Math.min(asked, capMs), asked, capped: asked > capMs };
105
105
  }
106
106
 
107
- async function follow(url, max = 5) {
107
+ async function follow(url, max = 5, opts = {}) {
108
108
  const hops = [];
109
109
  let cur = url;
110
110
  for (let i = 0; i < max; i++) {
111
- const r = await get(cur);
111
+ const r = await get(cur, opts);
112
112
  hops.push({ url: cur, status: r.status, location: r.location });
113
113
  if (r.status >= 300 && r.status < 400 && r.location) { cur = new URL(r.location, cur).href; continue; }
114
114
  return { final: cur, response: r, hops };
115
115
  }
116
- return { final: cur, response: await get(cur), hops };
116
+ return { final: cur, response: await get(cur, opts), hops };
117
117
  }
118
118
 
119
+ /** Служебный файл с переходом по переадресации: robots.txt, карта сайта, llms.txt. Сайт, у
120
+ * которого голый домен отвечает 301 на www, отдаёт их только по конечному адресу, а get() ходит
121
+ * с redirect: 'manual' и видел бы один 301. Возвращает ответ конечного адреса, url в нём тоже конечный. */
122
+ async function getFollowed(url, opts = {}) {
123
+ const f = await follow(url, 5, opts);
124
+ return { ...f.response, url: f.final };
125
+ }
126
+
127
+ /*
128
+ * Какие правовые страницы стоят за ссылкой. Смотрим каждую часть пути, а не только начало после
129
+ * косой черты: русский сайт пишет href="politika-konfidenczialnosti/" без неё, и до 30.09.2026
130
+ * агентство из Екатеринбурга получало красное «ни политики, ни условий нет» при живой политике.
131
+ * Оферта проверяется целым словом: испанское /ofertas/ это акции, а не условия.
132
+ */
133
+ const POLICY_SLUGS = [
134
+ ['privacy', /^(privacy|politika|[a-z-]*konfiden|политика|[а-я-]*конфиденциальн)/],
135
+ ['terms', /^(terms|usloviya|oferta(?![a-z])|polzovatelskoe-soglashenie|условия|оферта|пользовательское-соглашение)/],
136
+ ['consent', /^(soglasie|согласие)/],
137
+ ['legal', /^legal/],
138
+ ['cookie', /^cookie/],
139
+ ['disclaimer', /^disclaimer/],
140
+ ];
141
+ export function policyKinds(href) {
142
+ const h = String(href || '').toLowerCase();
143
+ if (/^(mailto|tel|javascript|data):/.test(h) || h.startsWith('#')) return [];
144
+ let path = h.replace(/^([a-z][a-z0-9+.-]*:)?\/\/[^/]+/, '').split(/[?#]/)[0];
145
+ try { path = decodeURIComponent(path); } catch { /* битая кодировка: читаем как есть */ }
146
+ return path.split('/').filter(Boolean).flatMap((seg) => { const k = POLICY_SLUGS.find(([, re]) => re.test(seg)); return k ? [k[0]] : []; });
147
+ }
119
148
  const row = (id, group, label, status, value, comment = '') => ({ id, group, label, status, value, comment });
120
149
 
121
150
  /**
@@ -209,7 +238,7 @@ function analysePage(url, html) {
209
238
  sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
210
239
  sameAsUrls: [...new Set(sameAs)],
211
240
  aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
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))],
241
+ policyPages: [...new Set(hrefsLower.flatMap(policyKinds))],
213
242
  author: /"author"\s*:/.test(ld),
214
243
  };
215
244
  // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
@@ -270,7 +299,7 @@ async function readSitemap(startUrl, { maxFiles = 6, budgetMs = 45000, maxUrls =
270
299
  const { url, depth } = queue.shift();
271
300
  if (seen.has(url) || depth > 3) continue;
272
301
  seen.add(url);
273
- const r = await get(url, timeout ? { timeout } : {});
302
+ const r = await getFollowed(url, timeout ? { timeout } : {});
274
303
  filesRead++;
275
304
  if (!r.ok) continue;
276
305
  // Спускаться вглубь можно только из индекса карт. Прежнее условие из-за приоритета операторов
@@ -304,7 +333,16 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
304
333
  checks.push(homeAnswered
305
334
  ? row('https', 'technical', 'HTTPS', home.final.startsWith('https://') ? 'ok' : 'bad', home.final.startsWith('https://') ? 'certificate active' : 'site served over http')
306
335
  : row('https', 'technical', 'HTTPS', 'bad', `the site did not answer${home.response.status ? `: HTTP ${home.response.status}` : ''}`, 'nothing below could be measured on a site that does not answer'));
307
- const altHost = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
336
+ /*
337
+ * Служебные файлы читаем с того хоста, где сайт на самом деле живёт. До 0.32.2 robots.txt и
338
+ * карта сайта брались с адреса, который ввёл человек: bristolpropertycentre.co.uk отвечает 301 на
339
+ * www, get() переадресацию не проходит, и отчёт писал «no sitemap found» и «1 pages sampled»,
340
+ * хотя в robots.txt на www стоит живая карта на сто страниц. Найдено 30.09.2026.
341
+ */
342
+ const siteOrigin = homeAnswered ? new URL(home.final).origin : origin;
343
+ const siteHost = new URL(siteOrigin).host;
344
+ const altHost = siteHost.startsWith('www.') ? siteHost.slice(4) : `www.${siteHost}`;
345
+ const ownHosts = new Set([host, siteHost, altHost]);
308
346
  /*
309
347
  * Цепочки переадресаций. Каждый прыжок это потерянное время у человека и потерянный вес ссылки
310
348
  * у поиска, а нейросети на длинной цепочке чаще бросают страницу вовсе. Один прыжок нормален
@@ -337,7 +375,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
337
375
  checks.push(row('ttfb', 'technical', 'Time to first byte (homepage)', home.response.ttfb > 2500 ? 'bad' : home.response.ttfb > 1200 ? 'warn' : 'ok', `${home.response.ttfb} ms`, 'single request from the auditor, not a lab test'));
338
376
 
339
377
  log('robots.txt');
340
- const robots = await get(`${origin}/robots.txt`);
378
+ const robots = await getFollowed(`${siteOrigin}/robots.txt`);
341
379
  const sitemapUrls = robots.ok ? [...robots.text.matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]) : [];
342
380
  // robots.txt is read per user-agent block: a "Disallow: /" under an AI crawler is a policy, under * it is a catastrophe.
343
381
  const blocks = []; let cur = null;
@@ -386,13 +424,13 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
386
424
  if (blockedTrainers.length) checks.push(row('robots-ai', 'geo', 'AI training crawlers blocked (policy)', 'na', `${blockedTrainers.length} agent(s) fully disallowed: ${blockedTrainers.join(', ')}`, 'a policy choice, not a defect: these systems will not train on the site, and will not cite it from their own crawl either'));
387
425
 
388
426
  log('sitemaps');
389
- const candidates = sitemapUrls.length ? sitemapUrls : [`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`];
427
+ const candidates = sitemapUrls.length ? sitemapUrls : [`${siteOrigin}/sitemap.xml`, `${siteOrigin}/sitemap_index.xml`, `${siteOrigin}/sitemap-index.xml`];
390
428
  let sitemapPages = []; let sitemapStatus = 'missing'; let sitemapSource = '';
391
429
  let sitemapRead = { filesRead: 0, nestedFound: 0, pending: 0, stopped: '' };
392
430
  for (const u of candidates) {
393
- const r = await get(u);
431
+ const r = await getFollowed(u);
394
432
  if (r.ok && /<(urlset|sitemapindex)/.test(r.text)) {
395
- sitemapRead = await readSitemap(u, { budgetMs: sitemapBudgetMs, maxFiles: sitemapMaxFiles });
433
+ sitemapRead = await readSitemap(r.url, { budgetMs: sitemapBudgetMs, maxFiles: sitemapMaxFiles });
396
434
  sitemapPages = sitemapRead.urls; sitemapStatus = 'ok'; sitemapSource = u; break;
397
435
  }
398
436
  if (sitemapUrls.includes(u)) { sitemapStatus = `HTTP ${r.status}`; sitemapSource = u; }
@@ -404,14 +442,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
404
442
  ? `${sitemapPages.length} URL(s) read from ${sitemapRead.filesRead} of ${sitemapRead.filesRead + sitemapRead.pending} sitemap file(s) in ${sitemapSource}`
405
443
  : `${sitemapPages.length} URL(s) in ${sitemapSource}`;
406
444
  checks.push(row('sitemap', 'technical', 'XML sitemap', sitemapStatus === 'ok' ? 'ok' : 'bad', sitemapValue, sitemapStatus !== 'ok' && sitemapUrls.length ? 'robots.txt points at a sitemap that does not answer' : sitemapRead.stopped ? 'a large nested sitemap: the pages below were sampled from the files that were read, not from every file' : ''));
407
- const foreign = sitemapPages.filter((u) => { try { return new URL(u).host !== host && new URL(u).host !== altHost; } catch { return true; } });
445
+ const foreign = sitemapPages.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return true; } });
408
446
  if (foreign.length) checks.push(row('sitemap-foreign', 'technical', 'Sitemap lists other hosts', 'bad', `${foreign.length} URL(s) on other hosts, e.g. ${foreign[0]}`));
409
447
 
410
448
  log('llms.txt');
411
- const llms = await get(`${origin}/llms.txt`);
449
+ const llms = await getFollowed(`${siteOrigin}/llms.txt`);
412
450
  const llmsIsText = llms.ok && !/<html/i.test(llms.text.slice(0, 500));
413
451
  const llmsLinks = llmsIsText ? [...llms.text.matchAll(/https?:\/\/[^\s)\]]+/g)].map((m) => m[0]) : [];
414
- const llmsForeign = llmsLinks.filter((u) => { try { const h = new URL(u).host; return h !== host && h !== altHost; } catch { return false; } });
452
+ const llmsForeign = llmsLinks.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return false; } });
415
453
  checks.push(row('llms', 'geo', 'llms.txt', !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : 'ok',
416
454
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
417
455
  !llms.ok ? 'answer engines get nothing to read; a generated index is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
@@ -420,7 +458,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
420
458
  // страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
421
459
  // удалили, а в индексе она осталась, и ИИ идёт цитировать редирект или пустоту. Проверяем
422
460
  // выборку своих ссылок, а не все: на большом сайте их тысячи, а беда видна и по двадцати.
423
- const llmsOwn = llmsLinks.filter((u) => { try { const h = new URL(u).host; return h === host || h === altHost; } catch { return false; } });
461
+ const llmsOwn = llmsLinks.filter((u) => { try { return ownHosts.has(new URL(u).host); } catch { return false; } });
424
462
  if (llmsOwn.length) {
425
463
  const sampleSize = Math.min(20, llmsOwn.length);
426
464
  const step = Math.max(1, Math.floor(llmsOwn.length / sampleSize));
@@ -448,13 +486,13 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
448
486
  // вызвать. Стандарты молодые, поэтому отсутствие это не провал, а предупреждение, и в тексте
449
487
  // прямо сказано, что это раннее и необязательное. Врать про обязательность мы не будем.
450
488
  log('agent surface');
451
- const agentCard = await get(`${origin}/.well-known/agent.json`);
489
+ const agentCard = await get(`${siteOrigin}/.well-known/agent.json`);
452
490
  const agentCardOk = agentCard.ok && /^\s*\{/.test(agentCard.text || '');
453
491
  checks.push(row('agent-card', 'geo', 'Agent card (/.well-known/agent.json)', agentCardOk ? 'ok' : 'warn',
454
492
  agentCardOk ? 'present and valid JSON' : agentCard.ok ? 'present but not JSON' : 'not present',
455
493
  agentCardOk ? '' : 'an early, optional standard: it is the file agent directories read to learn who you are and what you offer'));
456
494
 
457
- const apiCatalog = await get(`${origin}/.well-known/api-catalog`);
495
+ const apiCatalog = await get(`${siteOrigin}/.well-known/api-catalog`);
458
496
  checks.push(row('api-catalog', 'geo', 'API catalogue (RFC 9727)', apiCatalog.ok ? 'ok' : 'warn',
459
497
  apiCatalog.ok ? 'present' : 'not present',
460
498
  apiCatalog.ok ? '' : 'optional, and only worth adding when the business actually exposes an API for agents to call'));
@@ -476,9 +514,28 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
476
514
  // are read first rather than left to the luck of sitemap order. A 44 page site sampled 20 deep was
477
515
  // reporting "no messenger and no contact page" while both sat in the sitemap, unread.
478
516
  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;
479
- const trustFirst = rest.filter((u) => { try { return TRUST_PAGE.test(new URL(u).pathname); } catch { return false; } });
517
+ const isTrust = (u) => { try { return TRUST_PAGE.test(new URL(u).pathname); } catch { return false; } };
518
+ const trustFirst = rest.filter(isTrust);
480
519
  const pool = [home.final, ...trustFirst, ...rest.filter((u) => !trustFirst.includes(u))];
481
520
  const wanted = pool.slice(0, pages);
521
+ /*
522
+ * Обход по ссылкам. Карта дала меньше страниц, чем нужно (или её нет вовсе): недостающие
523
+ * добираем из ссылок на прочитанных страницах, по уровням от главной. Лишних запросов это не
524
+ * стоит, ссылки берутся из тех же страниц, что и так читаются в выборку. Добавлено 30.09.2026.
525
+ */
526
+ const crawl = wanted.length < pages;
527
+ const allowedByRobots = robotsAllows(robots.ok ? robots.text : '');
528
+ let fromLinks = 0;
529
+ const addLinks = (html, base) => {
530
+ if (!crawl || wanted.length >= pages) return;
531
+ // Ссылка на http:// у сайта на https это та же страница: читаем её по протоколу сайта, а не дублем с 301.
532
+ const siteProtocol = new URL(siteOrigin).protocol;
533
+ const fresh = internalLinks(html, base, ownHosts).map((l) => { const x = new URL(l); x.protocol = siteProtocol; return x.href; }).filter((l) => { const n = norm(l); if (seenUrl.has(n) || !allowedByRobots(l)) return false; seenUrl.add(n); return true; });
534
+ for (const l of [...fresh.filter(isTrust), ...fresh.filter((l) => !isTrust(l))]) {
535
+ if (wanted.length >= pages) break;
536
+ wanted.push(l); fromLinks += 1;
537
+ }
538
+ };
482
539
  const politeness = crawlDelayFrom(robots.ok ? robots.text : '');
483
540
  let gap = Math.max(pageDelayMs, politeness.ms);
484
541
  const sample = [];
@@ -497,11 +554,11 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
497
554
  } else if (r.ok) {
498
555
  refusedInARow = 0;
499
556
  }
500
- if (r.ok && /html/i.test(r.type)) sample.push(analysePage(u, r.text));
557
+ if (r.ok && /html/i.test(r.type)) { sample.push(analysePage(u, r.text)); addLinks(r.text, u); }
501
558
  else if (r.status >= 300 && r.status < 400) sample.push({ url: u, redirect: r.location, status: r.status });
502
559
  else sample.push({ url: u, status: r.status, error: r.error });
503
560
  }
504
- const reading = { wanted: wanted.length, read: sample.filter((x) => x.title !== undefined).length, stoppedAt, gapMs: gap, crawlDelay: politeness };
561
+ const reading = { wanted: wanted.length, read: sample.filter((x) => x.title !== undefined).length, stoppedAt, gapMs: gap, crawlDelay: politeness, fromSitemap: wanted.length - 1 - fromLinks, fromLinks };
505
562
  const good = sample.filter((p) => p.title !== undefined);
506
563
  const hp = homePage || good[0];
507
564
 
@@ -621,7 +678,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
621
678
  }
622
679
  const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
623
680
  if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
624
- if (/wordpress/i.test(hp?.generator || '')) { const x = await get(`${origin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
681
+ if (/wordpress/i.test(hp?.generator || '')) { const x = await get(`${siteOrigin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
625
682
 
626
683
  // ---- Off-page and trust ----------------------------------------------------------------
627
684
  // Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
@@ -748,6 +805,35 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
748
805
 
749
806
  const localised = localiseChecks(checks, lang);
750
807
  const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
808
+ /*
809
+ * Подписи, которые сборщик пишет сам, на языке отчёта. До 0.33.1 они были только английскими, и
810
+ * русский отчёт выходил с «Week 1», «The site itself» и «Fix: Privacy and terms» среди русского
811
+ * текста: черновик их не трогает, потому что в них нет заготовок {{...}}.
812
+ */
813
+ const ru = lang === 'ru';
814
+ const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
815
+ const S = ru ? {
816
+ rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Что продаётся'],
817
+ site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
818
+ siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
819
+ paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
820
+ limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в аудите по публичным сигналам не используются'],
821
+ fix: 'Исправить',
822
+ roadmap: [['Неделя 1', 'Срочное, быстрые победы'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Рост: поиск и ответы ИИ']],
823
+ geo: ['Понятно ли, кто вы', 'Упоминания на других сайтах', 'Отзывы и пресса'],
824
+ conv: ['Форма связи', 'Цена видна', 'Мессенджеры'],
825
+ } : {
826
+ rows: ['CMS / stack', 'Pages in sitemap', 'Language', 'What is sold'],
827
+ site: 'The site itself', free: 'Free, no account needed',
828
+ siteDetail: (n, viaLinks) => `robots.txt, the sitemaps, llms.txt, the homepage and ${n} sampled page(s), read exactly as any visitor reads them${viaLinks ? `; ${viaLinks} of the pages were found by following links from the homepage, because the sitemap listed too few` : ''}`,
829
+ paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
830
+ limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
831
+ fix: 'Fix',
832
+ roadmap: [['Week 1', 'Critical, quick wins'], ['Weeks 2 to 4', 'Foundation'], ['Months 2 to 3', 'Organic growth, AEO and GEO']],
833
+ geo: ['Entity clarity', 'Third-party mentions', 'Reviews and PR'],
834
+ conv: ['Contact form', 'Price visible', 'Messengers'],
835
+ };
836
+ const criticalShown = ru ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical;
751
837
 
752
838
  return {
753
839
  meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
@@ -757,23 +843,23 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
757
843
  scores,
758
844
  scoreBasis,
759
845
  summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
760
- overview: { rows: [['CMS / stack', hp?.generator || '{{stack}}'], ['Pages in sitemap', String(sitemapPages.length)], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
761
- critical: lang === 'ru' ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical,
846
+ overview: { rows: [[S.rows[0], hp?.generator || '{{stack}}'], [S.rows[1], String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
847
+ critical: criticalShown,
762
848
  checks: localised,
763
849
  content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
764
850
  aeo: { works: ['{{what already works}}'], blocks: ['{{what blocks answer-engine wins}}'], recommendation: '{{One recommendation with a page name.}}' },
765
- geo: { rows: [['Entity clarity', '{{status}}', '{{detail}}'], ['Third-party mentions', '{{status}}', '{{detail}}'], ['Reviews and PR', '{{status}}', '{{detail}}']], callout: '{{Brand disambiguation note or the single biggest GEO risk.}}' },
851
+ geo: { rows: S.geo.map((label) => [label, '{{status}}', '{{detail}}']), callout: '{{Brand disambiguation note or the single biggest GEO risk.}}' },
766
852
  offpage: { listed: ['{{where the brand is already listed}}'], note: '{{One paragraph.}}' },
767
- conversion: { rows: [['Contact form', '{{status}}'], ['Price visible', '{{status}}'], ['Messengers', '{{status}}']] },
853
+ conversion: { rows: S.conv.map((label) => [label, '{{status}}']) },
768
854
  sources: [
769
- { name: 'The site itself', access: 'Free, no account needed', detail: `robots.txt, the sitemaps, llms.txt, the homepage and ${good.length} sampled page(s), read exactly as any visitor reads them` },
770
- ...(links.ok ? [{ name: links.source, access: 'Paid tool, optional', detail: 'reported for context only, not counted in any score' }] : []),
855
+ { name: S.site, access: S.free, detail: S.siteDetail(good.length, fromLinks) },
856
+ ...(links.ok ? [{ name: links.source, access: S.paid, detail: S.paidDetail }] : []),
771
857
  ],
772
- limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
858
+ limitations: S.limitations,
773
859
  roadmap: [
774
- { badge: 'Week 1', title: 'Critical, quick wins', items: critical.map((c) => `Fix: ${c.title}`).concat(['{{...}}']) },
775
- { badge: 'Weeks 2 to 4', title: 'Foundation', items: ['{{pillar pages, alt text, Search Console and Bing connected}}'] },
776
- { badge: 'Months 2 to 3', title: 'Organic growth, AEO and GEO', items: ['{{content clusters, schema, PR, reviews}}'] },
860
+ { badge: S.roadmap[0][0], title: S.roadmap[0][1], items: criticalShown.map((c) => `${S.fix}: ${c.title}`).concat(['{{...}}']) },
861
+ { badge: S.roadmap[1][0], title: S.roadmap[1][1], items: ['{{pillar pages, alt text, Search Console and Bing connected}}'] },
862
+ { badge: S.roadmap[2][0], title: S.roadmap[2][1], items: ['{{content clusters, schema, PR, reviews}}'] },
777
863
  ],
778
864
  closing: '{{Key message for the client in three sentences.}}',
779
865
  // В выборке остаётся и то, из чего считается работа с текстами: без этих полей пакет
package/src/i18n.mjs CHANGED
@@ -149,7 +149,7 @@ const RU = {
149
149
  },
150
150
  'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'самый дешёвый внешний сигнал, и он бесплатный' : '') },
151
151
  'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
152
- 'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка')}`) },
152
+ 'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
153
153
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
154
154
  'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
155
155
  'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
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: 'Not one page opens with a short direct answer, and that opening is exactly what gets quoted.',
90
- ru: 'Ни на одной странице нет короткого прямого ответа в первом абзаце, а цитируют именно его.',
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') => (OWNER_LINES[id] || {})[lang === 'ru' ? 'ru' : '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
 
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Сайт без карты сайта читается по ссылкам, а не одной главной.
4
+ *
5
+ * 30.09.2026 elitelivingrealty.com и solohomes.co.uk (на www нет ни robots.txt, ни карты) дали
6
+ * в отчёте выборку из одной главной. Здесь: разбор ссылок и правил robots.txt отдельно, потом
7
+ * живой сервер без карты, где страницы находятся только по ссылкам, на двух уровнях.
8
+ */
9
+ import { createServer } from 'node:http';
10
+ import { lookup } from 'node:dns/promises';
11
+ import { internalLinks, robotsAllows } from './visibility.mjs';
12
+ import { collect } from './collect.mjs';
13
+ import { checkVisibility } from './visibility.mjs';
14
+
15
+ let bad = 0;
16
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
17
+ const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
18
+
19
+ // ---- разбор ссылок
20
+ {
21
+ const html = `<a href="/buy/">Buy</a><a href="/buy">dup</a><a href="https://www.x.com/rent/?page=2#top">Rent</a>
22
+ <a href="https://x.com/sell/">apex</a><a href="https://other.com/">other</a><a href="mailto:a@x.com">m</a>
23
+ <a href="tel:+1">t</a><a href="#top">anchor</a><a href="/brochure.pdf">pdf</a><a href="/about.html">about</a>
24
+ <a href="/cart/">cart</a><a href="/tag/villas/">tag</a><a href="/x/" rel="nofollow">nf</a><a href="javascript:void(0)">js</a>`;
25
+ is('свои страницы, без повторов, файлов и служебных', internalLinks(html, 'https://www.x.com/'),
26
+ ['https://www.x.com/buy/', 'https://www.x.com/rent/', 'https://x.com/sell/', 'https://www.x.com/about.html']);
27
+ is('относительные ссылки от <base>', internalLinks('<base href="https://www.x.com/en/"><a href="team/">t</a>', 'https://www.x.com/'), ['https://www.x.com/en/team/']);
28
+ is('битый html не роняет разбор', internalLinks(undefined, 'https://x.com/'), []);
29
+ }
30
+
31
+ // ---- robots.txt
32
+ {
33
+ const allow = robotsAllows('User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /private/\nDisallow: /*.php$\nAllow: /private/open/\n');
34
+ ok('обычная страница разрешена', allow('https://x.com/buy/'));
35
+ ok('закрытый раздел нет', !allow('https://x.com/private/a/'));
36
+ ok('Allow длиннее Disallow побеждает', allow('https://x.com/private/open/a/'));
37
+ ok('звёздочка и доллар', !allow('https://x.com/a/b.php') && allow('https://x.com/a/b.php5'));
38
+ ok('блок чужого робота не применяется', allow('https://x.com/'));
39
+ ok('пустой robots.txt разрешает всё', robotsAllows('')('https://x.com/a/'));
40
+ }
41
+
42
+ // ---- живой сервер без карты сайта
43
+ let dnsOk = true;
44
+ try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
45
+ if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(bad ? 1 : 0); }
46
+
47
+ const LINKS = {
48
+ '/': ['/buy/', '/rent/', '/private/', '/contact/', 'https://elsewhere.example/'],
49
+ '/buy/': ['/buy/villas/', '/buy/flats/', '/'],
50
+ '/rent/': ['/rent/long-term/'],
51
+ '/contact/': [], '/buy/villas/': [], '/buy/flats/': [], '/rent/long-term/': [], '/private/': [],
52
+ };
53
+ const page = (path) => `<!doctype html><html lang="en"><head><title>Page ${path}</title>
54
+ <meta name="description" content="A page description long enough not to count as empty for the check."></head>
55
+ <body><main><h1>Page ${path}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}
56
+ <nav>${LINKS[path].map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
57
+ const hits = [];
58
+ const server = createServer((req, res) => {
59
+ hits.push(req.url);
60
+ if (req.url === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end('User-agent: *\nDisallow: /private/\n'); }
61
+ if (LINKS[req.url]) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(req.url)); }
62
+ res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
63
+ });
64
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
65
+ const base = `http://lvh.me:${server.address().port}`;
66
+
67
+ try {
68
+ const a = await collect(`${base}/`, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
69
+ const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
70
+ ok('карты нет, и отчёт это говорит', (a.checks.find((c) => c.id === 'sitemap') || {}).status === 'bad');
71
+ is('прочитаны все открытые страницы по ссылкам, двумя уровнями', [...read].sort(), ['/', '/buy/', '/buy/flats/', '/buy/villas/', '/contact/', '/rent/', '/rent/long-term/']);
72
+ ok('закрытое в robots.txt не читается', !hits.includes('/private/'));
73
+ ok('страница контактов идёт раньше остальных ссылок', read.indexOf('/contact/') < read.indexOf('/buy/'));
74
+ is('счёт найденных по ссылкам', a.reading.fromLinks, 6);
75
+ ok('в источниках сказано, откуда страницы', /found by following links/.test(a.sources[0].detail));
76
+
77
+ const few = await collect(`${base}/`, { pages: 3, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
78
+ is('предел страниц соблюдается', few.sample.length, 3);
79
+
80
+ const v = await checkVisibility(`${base}/`, { lang: 'en', samplePages: 5, budgetMs: 8000 });
81
+ ok('бесплатная проверка прочитала сайт', v.ok);
82
+ const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
83
+ ok(`бесплатная проверка взяла страницы со ссылок главной (${vRead.join(', ')})`, vRead.length >= 4 && vRead.includes('/buy/') && !vRead.includes('/private/'));
84
+ } finally { server.close(); }
85
+
86
+ if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
87
+ console.log('\nвсё сходится');
@@ -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');
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Голый домен, который отвечает 301 на www, должен читаться так же, как сам www.
4
+ *
5
+ * 30.09.2026 bristolpropertycentre.co.uk получил в отчёте «no sitemap found at the usual paths» и
6
+ * «1 pages sampled»: robots.txt и карта сайта брались с введённого адреса без перехода по 301, а
7
+ * выборка в бесплатной проверке отсеивала страницы карты, потому что они на www. Тот же сайт,
8
+ * введённый с www, давал сто страниц. Здесь это держится на живом сервере: lvh.me и www.lvh.me оба
9
+ * указывают на 127.0.0.1, и сервер по заголовку Host отправляет голый домен на www.
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { lookup } from 'node:dns/promises';
13
+ import { collect } from './collect.mjs';
14
+ import { checkVisibility } from './visibility.mjs';
15
+
16
+ let bad = 0;
17
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
18
+
19
+ let dnsOk = true;
20
+ try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1' && (await lookup('www.lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
21
+ if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
22
+
23
+ const PAGES = ['/', '/buy/', '/rent/', '/sell/', '/about/', '/contact/'];
24
+ const page = (title) => `<!doctype html><html lang="en"><head><title>${title}</title>
25
+ <meta name="description" content="A page description long enough not to count as empty for the check.">
26
+ </head><body><main><h1>${title}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}</main></body></html>`;
27
+
28
+ const server = createServer((req, res) => {
29
+ const host = (req.headers.host || '').toLowerCase();
30
+ const www = `http://www.${host.replace(/^www\./, '')}`;
31
+ if (!host.startsWith('www.')) { res.writeHead(301, { location: `${www}${req.url}` }); return res.end(); }
32
+ const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
33
+ if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
34
+ if (req.url === '/llms.txt') return send(200, 'text/plain', `# Site\n\n- [Buy](${www}/buy/)\n`);
35
+ if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
36
+ if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(`Page ${req.url}`));
37
+ return send(404, 'text/plain', 'no');
38
+ });
39
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
40
+ const port = server.address().port;
41
+ const apex = `http://lvh.me:${port}/`;
42
+
43
+ try {
44
+ const a = await collect(apex, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
45
+ const check = (id) => a.checks.find((c) => c.id === id) || {};
46
+ ok('конечный адрес сайта на www', a.meta.site.startsWith(`http://www.lvh.me:${port}`));
47
+ ok(`robots.txt прочитан через 301 (${check('robots').status}: ${check('robots').value})`, check('robots').status === 'ok');
48
+ ok(`карта сайта найдена (${check('sitemap').value})`, check('sitemap').status === 'ok' && /6 URL/.test(check('sitemap').value));
49
+ ok('карта не считается чужим хостом', !a.checks.some((c) => c.id === 'sitemap-foreign'));
50
+ ok(`llms.txt прочитан через 301 (${check('llms').value})`, check('llms').status === 'ok');
51
+ ok('в обзоре шесть страниц в карте', a.overview.rows.find((r) => r[0] === 'Pages in sitemap')?.[1] === '6');
52
+ const sampled = Number((a.sources[0].detail.match(/homepage and (\d+) sampled/) || [])[1]);
53
+ ok(`выборка не одна главная (${sampled} страниц)`, sampled >= 5);
54
+ ok(`www-канонизация названа от голого домена к www (${check('www').value})`, /^lvh\.me:\d+ redirects to www\.lvh\.me/.test(check('www').value));
55
+
56
+ const v = await checkVisibility(apex, { lang: 'en', samplePages: 5, budgetMs: 8000 });
57
+ ok('бесплатная проверка прочитала сайт', v.ok);
58
+ ok(`бесплатная проверка нашла карту (${v.sitemap?.count})`, v.sitemap?.found && v.sitemap.count === 6);
59
+ ok(`в выборке не только главная (${v.sample?.length} страниц)`, (v.sample?.length || 0) >= 3);
60
+ } finally { server.close(); }
61
+
62
+ if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
63
+ console.log('\nвсё сходится');
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Русский сайт в русском отчёте: правовые страницы находятся по русским адресам, и в audit.json
4
+ * нет английских подписей.
5
+ *
6
+ * 30.09.2026 образец аудита агентства из Екатеринбурга (collect --lang ru) дал красное «ни политики
7
+ * конфиденциальности, ни условий на сайте нет» при ссылке href="politika-konfidenczialnosti/", а в
8
+ * плане работ, обзоре, источниках и ограничениях стояли «Week 1», «Fix: Privacy and terms»,
9
+ * «The site itself» и «Analytics: no traffic or behaviour data».
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { mkdtemp, readFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import path from 'node:path';
15
+ import { collect, policyKinds } from './collect.mjs';
16
+ import { draftNarrative } from './narrative.mjs';
17
+ import { render } from './render.mjs';
18
+
19
+ let bad = 0;
20
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
21
+ const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
22
+
23
+ // ---- какие правовые страницы стоят за ссылкой
24
+ is('русская политика без косой черты в начале', policyKinds('politika-konfidenczialnosti/'), ['privacy']);
25
+ is('русская политика с косой чертой', policyKinds('/politika-konfidencialnosti/'), ['privacy']);
26
+ is('конфиденциальность отдельным словом', policyKinds('/konfidencialnost/'), ['privacy']);
27
+ is('согласие на обработку данных', policyKinds('/soglasie-na-obrabotku-personalnyh-dannyh/'), ['consent']);
28
+ is('пользовательское соглашение это условия, а не согласие', policyKinds('/polzovatelskoe-soglashenie/'), ['terms']);
29
+ is('условия и оферта', [policyKinds('usloviya-ispolzovaniya/'), policyKinds('/docs/oferta.pdf')], [['terms'], ['terms']]);
30
+ is('кириллица в адресе, в том числе закодированная', [policyKinds('/политика-конфиденциальности/'), policyKinds('/%D0%BE%D1%84%D0%B5%D1%80%D1%82%D0%B0/')], [['privacy'], ['terms']]);
31
+ is('полный адрес', policyKinds('https://dial93.ru/politika-konfidenczialnosti/'), ['privacy']);
32
+ is('английские адреса как раньше', [policyKinds('/privacy-policy'), policyKinds('/en/terms-of-use/'), policyKinds('/cookies'), policyKinds('/legal/'), policyKinds('/disclaimer')], [['privacy'], ['terms'], ['cookie'], ['legal'], ['disclaimer']]);
33
+ is('испанские акции не условия', policyKinds('/ofertas/'), []);
34
+ is('почта, телефон и якорь не страницы', [policyKinds('mailto:privacy@x.ru'), policyKinds('tel:+7'), policyKinds('#privacy')], [[], [], []]);
35
+ is('обычные страницы не правовые', [policyKinds('/kvartiry/'), policyKinds('/o-kompanii/'), policyKinds('/politics-news/')], [[], [], []]);
36
+
37
+ // ---- живой русский сайт, отчёт по-русски
38
+ const PAGES = ['/', '/kvartiry/', '/doma/', '/kontakty/'];
39
+ const page = (p) => `<!doctype html><html lang="ru"><head><title>Агентство недвижимости ${p}</title>
40
+ <meta name="description" content="Квартиры и дома в Екатеринбурге: подбор, проверка и сопровождение сделки под ключ.">
41
+ </head><body><main><h1>Недвижимость ${p}</h1>${'<p>Квартира в новом доме, 54 квадратных метра, цена 7,2 млн рублей, сдача в 2027 году.</p>'.repeat(12)}</main>
42
+ <footer><a href="politika-konfidenczialnosti/">Политика конфиденциальности</a> <a href="/soglasie-na-obrabotku/">Согласие на обработку</a></footer></body></html>`;
43
+ const server = createServer((req, res) => {
44
+ const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
45
+ const www = `http://${req.headers.host}`;
46
+ if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
47
+ if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
48
+ if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(req.url));
49
+ return send(404, 'text/plain', 'нет');
50
+ });
51
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
52
+ const base = `http://127.0.0.1:${server.address().port}`;
53
+
54
+ // Английские подписи, которые сборщик писал сам до 0.33.1, и общий признак английской фразы.
55
+ const ENGLISH = /\b(Week|Weeks|Months|Critical, quick wins|Foundation|Organic growth|Fix:|CMS \/ stack|Pages in sitemap|Language|What is sold|The site itself|Free, no account|read exactly as|sampled page|Search Console: no|Analytics: no|Keyword and backlink|external tier|Entity clarity|Third-party mentions|Reviews and PR|Contact form|Price visible|Messengers|Paid tool)\b/;
56
+ const strings = (o, at = '', out = []) => {
57
+ if (typeof o === 'string') out.push([at, o]);
58
+ else if (Array.isArray(o)) o.forEach((x, i) => strings(x, `${at}[${i}]`, out));
59
+ else if (o && typeof o === 'object') for (const k of Object.keys(o)) strings(o[k], at ? `${at}.${k}` : k, out);
60
+ return out;
61
+ };
62
+ // Что пишет сам сборщик. Проверки, выборка и сырые замеры несут адреса и значения со страниц.
63
+ const OWN = ['overview', 'critical', 'geo', 'conversion', 'sources', 'limitations', 'roadmap'];
64
+
65
+ try {
66
+ const a = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'ru', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
67
+ const pol = a.checks.find((c) => c.id === 'trust-policies') || {};
68
+ is('политика и согласие найдены, проверка зелёная', pol.status, 'ok');
69
+ ok(`и подписаны по-русски (${pol.value})`, /политика/.test(pol.value) && /согласие/.test(pol.value) && !/privacy|consent/.test(pol.value));
70
+
71
+ const leaks = strings(Object.fromEntries(OWN.map((k) => [k, a[k]]))).filter(([, s]) => ENGLISH.test(s) && !s.includes('{{'));
72
+ is('в русском audit.json нет английских подписей', leaks, []);
73
+ is('план работ', a.roadmap.map((p) => p.badge), ['Неделя 1', 'Недели 2-4', 'Месяцы 2-3']);
74
+ const badRu = a.checks.filter((c) => c.status === 'bad').map((c) => `Исправить: ${c.label}`);
75
+ ok(`пункты плана по русским названиям проверок (${a.roadmap[0].items[0]})`, badRu.length > 0 && JSON.stringify(a.roadmap[0].items.slice(0, -1)) === JSON.stringify(badRu));
76
+ ok('источник «Сам сайт» с числом страниц словами', a.sources[0].name === 'Сам сайт' && /и 4 страницы выборки/.test(a.sources[0].detail));
77
+
78
+ const d = draftNarrative(a, { lang: 'ru' });
79
+ const after = strings(Object.fromEntries(OWN.map((k) => [k, d[k]]))).filter(([, s]) => ENGLISH.test(s));
80
+ is('и после черновика тоже', after, []);
81
+ is('черновик нашёл русские строки обзора', d.overview.rows.map((r) => r[1].includes('{{')), [false, false, false, false]);
82
+
83
+ const dir = await mkdtemp(path.join(tmpdir(), 'operstack-ru-'));
84
+ const out = path.join(dir, 'ru.html');
85
+ await render(d, { out });
86
+ const text = (await readFile(out, 'utf8')).replace(/<style[\s\S]*?<\/style>|<script[\s\S]*?<\/script>/gi, ' ').replace(/<[^>]+>/g, ' ');
87
+ const m = text.match(ENGLISH);
88
+ ok(`в напечатанном отчёте тоже${m ? `: «${text.slice(Math.max(0, m.index - 60), m.index + 60).replace(/\s+/g, ' ')}»` : ''}`, !m);
89
+
90
+ const en = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'en', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
91
+ is('английский отчёт не изменился', [en.roadmap[0].badge, en.sources[0].name, en.overview.rows[1][0], en.limitations.length], ['Week 1', 'The site itself', 'Pages in sitemap', 3]);
92
+ } finally { server.close(); }
93
+
94
+ if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
95
+ console.log('\nвсё сходится');
@@ -225,6 +225,86 @@ export function normaliseInput(input) {
225
225
  return u.href;
226
226
  }
227
227
 
228
+ /*
229
+ * Страницы сайта по ссылкам, когда карта сайта их не даёт.
230
+ *
231
+ * Простым языком. Выборка страниц бралась только из карты сайта. У сайта без карты отчёт читал
232
+ * одну главную и судил по ней обо всём сайте: так 30.09.2026 вышли elitelivingrealty.com и
233
+ * solohomes.co.uk, у которых на www нет ни robots.txt, ни карты. Теперь, если карта дала меньше
234
+ * страниц, чем нужно, недостающие берутся из ссылок на уже прочитанных страницах: сначала с
235
+ * главной, потом со следующего уровня. Ровно так сайт видит любой поисковый робот.
236
+ *
237
+ * Берём только свои страницы (тот же домен, с www или без), без параметров и якорей, без файлов
238
+ * и служебных разделов, и не берём то, что robots.txt закрывает для всех роботов.
239
+ */
240
+ const bare = (h) => String(h || '').toLowerCase().replace(/^www\./, '');
241
+
242
+ /** Файлы и служебные разделы: это не страницы, по которым судят о сайте. */
243
+ const FILE_EXT = /\.(?!html?$|php$|aspx?$)[a-z0-9]{2,5}$/i;
244
+ const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|login|log-in|signin|sign-in|register|logout|wp-admin|wp-login\.php|wp-json|feed|rss|tag|tags|author|search|cdn-cgi)(\/|$)/i;
245
+
246
+ /**
247
+ * Свои ссылки страницы в порядке появления, без повторов.
248
+ * @param {string} html
249
+ * @param {string} baseUrl адрес страницы, от него считаются относительные ссылки
250
+ * @param {Iterable<string>} [ownHosts] хосты сайта; по умолчанию хост baseUrl, с www и без
251
+ */
252
+ export function internalLinks(html, baseUrl, ownHosts = []) {
253
+ let base;
254
+ try { base = new URL(baseUrl); } catch { return []; }
255
+ const own = new Set([bare(base.host), ...[...ownHosts].map(bare)]);
256
+ // Без парсера: этот файл живёт копией на сайтах и не тянет зависимостей. Скрипты, стили и
257
+ // комментарии вырезаются, чтобы ссылка внутри строки скрипта не считалась ссылкой страницы.
258
+ const src = String(html || '').replace(/<!--[\s\S]*?-->/g, ' ').replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/gi, ' ');
259
+ const attrOf = (tag, name) => { const m = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); return m ? (m[1] ?? m[2] ?? m[3] ?? '').replace(/&amp;/gi, '&') : null; };
260
+ const baseTag = src.match(/<base\b[^>]*>/i);
261
+ const baseHref = baseTag ? attrOf(baseTag[0], 'href') : null;
262
+ let resolveFrom = base;
263
+ if (baseHref) { try { resolveFrom = new URL(baseHref, base); } catch { /* битый <base> не мешает */ } }
264
+ const out = []; const seen = new Set();
265
+ for (const m of src.matchAll(/<a\b[^>]*>/gi)) {
266
+ const href = (attrOf(m[0], 'href') || '').trim();
267
+ if (!href || /^(#|mailto:|tel:|javascript:|data:|sms:|whatsapp:)/i.test(href)) continue;
268
+ if (/\bnofollow\b/i.test(attrOf(m[0], 'rel') || '')) continue;
269
+ let u;
270
+ try { u = new URL(href, resolveFrom); } catch { continue; }
271
+ if (!/^https?:$/.test(u.protocol) || !own.has(bare(u.host))) continue;
272
+ u.hash = ''; u.search = '';
273
+ if (FILE_EXT.test(u.pathname) || UTILITY.test(u.pathname)) continue;
274
+ const key = `${bare(u.host)}${u.pathname.replace(/\/$/, '')}`.toLowerCase();
275
+ if (seen.has(key)) continue;
276
+ seen.add(key);
277
+ out.push(u.href);
278
+ }
279
+ return out;
280
+ }
281
+
282
+ /**
283
+ * Разрешает ли robots.txt адрес роботу без своего блока (User-agent: *). Правило самое длинное
284
+ * совпадение, Allow побеждает при равной длине, звёздочка и знак доллара как у Google.
285
+ */
286
+ export function robotsAllows(robotsText) {
287
+ const rules = []; let inStar = false; let sawRule = false;
288
+ for (const raw of String(robotsText || '').split(/\r?\n/)) {
289
+ const line = raw.replace(/#.*$/, '').trim(); if (!line) continue;
290
+ const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i); if (!m) continue;
291
+ const key = m[1].toLowerCase(); const value = m[2].trim();
292
+ if (key === 'user-agent') { if (sawRule) { inStar = false; sawRule = false; } if (value === '*') inStar = true; continue; }
293
+ if (key === 'allow' || key === 'disallow') { sawRule = true; if (inStar && value) rules.push({ allow: key === 'allow', value }); }
294
+ }
295
+ const compiled = rules.map((r) => ({ ...r, re: new RegExp(`^${r.value.replace(/[.+?^{}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\\?\$$/, '$')}`) }));
296
+ return (url) => {
297
+ let path;
298
+ try { const u = new URL(url); path = u.pathname + u.search; } catch { return false; }
299
+ let best = null;
300
+ for (const r of compiled) {
301
+ if (!r.re.test(path)) continue;
302
+ if (!best || r.value.length > best.value.length || (r.value.length === best.value.length && r.allow)) best = r;
303
+ }
304
+ return !best || best.allow;
305
+ };
306
+ }
307
+
228
308
  async function get(url, { timeout = 6000, method = 'GET' } = {}) {
229
309
  const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
230
310
  const started = Date.now();
@@ -567,7 +647,17 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
567
647
  }
568
648
  const homePage = analysePage(home.text, home.url);
569
649
  const robots = parseRobots(robotsRes.ok ? robotsRes.text : '');
570
- const sitemap = left() > 1500 ? await readSitemap(origin, robotsRes.ok ? robotsRes.text : '', () => Math.min(3000, left() - 300)) : { found: false, url: '', count: 0, lastmod: false, pages: [], skipped: true };
650
+ /*
651
+ * Где сайт живёт на самом деле. Голый домен с 301 на www отдаёт robots.txt через переадресацию,
652
+ * и карта в нём перечисляет адреса на www. До 0.32.2 выборка брала только страницы, начинающиеся
653
+ * с введённого адреса, и на таком сайте читала одну главную. Найдено 30.09.2026 на
654
+ * bristolpropertycentre.co.uk.
655
+ */
656
+ const siteOrigin = (() => { try { return new URL(home.url).origin; } catch { return origin; } })();
657
+ const bare = (h) => h.replace(/^www\./, '').toLowerCase();
658
+ const ownBare = new Set([bare(host), bare(new URL(siteOrigin).host)]);
659
+ const isOwn = (u) => { try { return ownBare.has(bare(new URL(u).host)); } catch { return false; } };
660
+ const sitemap = left() > 1500 ? await readSitemap(siteOrigin, robotsRes.ok ? robotsRes.text : '', () => Math.min(3000, left() - 300)) : { found: false, url: '', count: 0, lastmod: false, pages: [], skipped: true };
571
661
  let sampled = [];
572
662
  /*
573
663
  * Сколько страниц выборки мы собирались прочитать. Нужно, чтобы отличить «сайт маленький» от
@@ -577,7 +667,17 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
577
667
  * выглядело как «стало лучше», а это ровно то, чего продукт обещает не делать.
578
668
  */
579
669
  let wantedPages = 0;
580
- if (left() > 2000 && sitemap.pages.length) {
670
+ /*
671
+ * Карта не дала страниц (или дала меньше, чем нужно): добираем из ссылок главной. Без этого
672
+ * сайт без карты мерился по одной главной. Запросов не добавляет, главная уже прочитана.
673
+ * Добавлено 30.09.2026.
674
+ */
675
+ const fromSitemap = sitemap.pages.filter(isOwn);
676
+ const allowedByRobots = robotsAllows(robotsRes.ok ? robotsRes.text : '');
677
+ const fromHome = fromSitemap.length > samplePages ? [] : internalLinks(home.text, home.url, [host]).filter((l) => allowedByRobots(l));
678
+ const poolSeen = new Set();
679
+ const pool = [...fromSitemap, ...fromHome].filter((u) => { const k = u.replace(/\/$/, '').toLowerCase(); return poolSeen.has(k) ? false : poolSeen.add(k); });
680
+ if (left() > 2000 && pool.length) {
581
681
  const norm = (u) => u.replace(/\/$/, '').toLowerCase();
582
682
  /*
583
683
  * Какие страницы берём в выборку.
@@ -595,7 +695,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
595
695
  */
596
696
  const depth = (u) => { try { return new URL(u).pathname.replace(/\/$/, '').split('/').filter(Boolean).length; } catch { return 99; } };
597
697
  const section = (u) => { try { return new URL(u).pathname.split('/').filter(Boolean)[0] || ''; } catch { return ''; } };
598
- const candidates = sitemap.pages.filter((p) => norm(p) !== norm(home.url) && p.startsWith(origin));
698
+ const candidates = pool.filter((p) => norm(p) !== norm(home.url));
599
699
  const byDepth = [...candidates].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
600
700
  const picks = [];
601
701
  const usedSections = new Set();