@operstack/audit 0.32.1 → 0.33.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/collect.mjs +117 -31
- package/src/i18n.mjs +1 -1
- package/src/prompts.mjs +141 -10
- package/src/test-crawl-links.mjs +87 -0
- package/src/test-prompts.mjs +31 -1
- package/src/test-redirect-host.mjs +63 -0
- package/src/test-ru-report.mjs +95 -0
- package/src/visibility.mjs +103 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.2",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs",
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 : [`${
|
|
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
|
|
431
|
+
const r = await getFollowed(u);
|
|
394
432
|
if (r.ok && /<(urlset|sitemapindex)/.test(r.text)) {
|
|
395
|
-
sitemapRead = await readSitemap(
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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(`${
|
|
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(`${
|
|
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
|
|
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(`${
|
|
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: [[
|
|
761
|
-
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:
|
|
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:
|
|
853
|
+
conversion: { rows: S.conv.map((label) => [label, '{{status}}']) },
|
|
768
854
|
sources: [
|
|
769
|
-
{ name:
|
|
770
|
-
...(links.ok ? [{ name: links.source, access:
|
|
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:
|
|
858
|
+
limitations: S.limitations,
|
|
773
859
|
roadmap: [
|
|
774
|
-
{ badge:
|
|
775
|
-
{ badge:
|
|
776
|
-
{ badge:
|
|
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/prompts.mjs
CHANGED
|
@@ -114,6 +114,140 @@ const P = {
|
|
|
114
114
|
ru: { task: 'Возьми страницы короче трёхсот слов и допиши их фактами, которые знает владелец: условия, цены, сроки, ограничения, частые вопросы покупателей с ответами. Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов и оставь страницу как есть.', verify: 'Страница стала длиннее трёхсот слов, и каждый добавленный абзац содержит факт, а не общие слова.' },
|
|
115
115
|
en: { task: 'Take the pages under three hundred words and fill them with facts the owner has: terms, prices, timings, limits, the questions buyers actually ask with the answers. Do not pad for length. Where the facts are missing, write the owner a list of questions and leave the page alone.', verify: 'The page is over three hundred words and every added paragraph carries a fact rather than filler.' },
|
|
116
116
|
},
|
|
117
|
+
/*
|
|
118
|
+
* Ниже задания для проверок, у которых до 0.33.2 своего текста не было: «Задача» в файле для
|
|
119
|
+
* клиента повторяла название проверки или пояснение «это не наша работа». Найдено 01.10.2026 на
|
|
120
|
+
* первых настоящих выдачах (dial93.ru, bristolpropertycentre.co.uk). Тест test-prompts.mjs
|
|
121
|
+
* теперь требует задание у каждой проверки, которая может выйти находкой.
|
|
122
|
+
*/
|
|
123
|
+
https: {
|
|
124
|
+
ru: { task: 'Сделай так, чтобы сайт открывался по https:// с действующим сертификатом, а адрес с http:// постоянно (кодом 301) переводил на https://. Сертификат выпускает хостинг: у большинства это одна галочка или бесплатный Let\'s Encrypt в панели. Если сайт сейчас не отвечает вовсе, сначала выясни у хостинга, почему, остальные задачи без этого бессмысленны.', verify: 'Главная открывается по https:// без предупреждения браузера, а http:// отвечает кодом 301 на тот же адрес с https://.' },
|
|
125
|
+
en: { task: 'Make the site open over https:// with a valid certificate, and make the http:// address redirect permanently (301) to https://. The host issues the certificate: on most hosts it is one switch or a free Let\'s Encrypt option in the panel. If the site does not answer at all right now, find out from the host why first; nothing else here matters until it does.', verify: 'The home page opens over https:// with no browser warning, and http:// answers 301 to the same address on https://.' },
|
|
126
|
+
},
|
|
127
|
+
www: {
|
|
128
|
+
ru: { task: 'Выбери один основной адрес сайта (с www или без, тот, что уже стоит в canonical и в карте сайта) и настрой постоянную переадресацию (код 301) со второго варианта на основной, с сохранением пути страницы. Если второй вариант сейчас не открывается вовсе, добавь для него запись в DNS и ту же переадресацию: люди набирают адрес по-разному.', verify: 'Второй вариант адреса, главная и любая внутренняя страница, отвечает кодом 301 на тот же путь на основном адресе.' },
|
|
129
|
+
en: { task: 'Pick one main address for the site (with www or without, whichever is already in the canonical tags and the sitemap) and redirect the other variant to it permanently (301), keeping the page path. If the other variant does not resolve at all, add a DNS record for it and the same redirect: people type the address both ways.', verify: 'The other variant, for the home page and for any inner page, answers 301 to the same path on the main address.' },
|
|
130
|
+
},
|
|
131
|
+
ttfb: {
|
|
132
|
+
ru: { task: 'Сократи время первого ответа главной до одной секунды: включи кеширование готовых страниц (плагин кеша, кеш на уровне хостинга или CDN), убери с главной тяжёлые запросы к базе при каждом открытии. Если сайт на дешёвом общем хостинге и кеш не помогает, напиши владельцу, что нужен тариф быстрее: менять хостинг без его решения нельзя.', verify: 'Три замера подряд (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают меньше 1,2 секунды.' },
|
|
133
|
+
en: { task: 'Bring the home page time to first byte under one second: turn on full-page caching (a cache plugin, host-level cache or a CDN) and remove heavy database work done on every home page load. If the site sits on cheap shared hosting and caching does not help, tell the owner a faster plan is needed: do not move hosts without their decision.', verify: 'Three runs in a row of curl -o /dev/null -w "%{time_starttransfer}" on the home page show under 1.2 seconds.' },
|
|
134
|
+
},
|
|
135
|
+
'robots-block': {
|
|
136
|
+
ru: { task: 'Убери из robots.txt строку Disallow: / в блоке User-agent: *, она запрещает поисковикам читать весь сайт. Закрытыми оставь только служебные адреса (корзина, личный кабинет, поиск по сайту). Если запрет стоял специально, например на тестовой копии, уточни у владельца, прежде чем снимать.', verify: 'В блоке User-agent: * файла /robots.txt нет строки Disallow: /, а главная открыта для чтения роботами.' },
|
|
137
|
+
en: { task: 'Remove Disallow: / from the User-agent: * block in robots.txt: it tells search engines not to read the whole site. Keep only utility addresses closed (cart, account, site search). If the block was put there on purpose, for a staging copy say, check with the owner before lifting it.', verify: 'The User-agent: * block of /robots.txt has no Disallow: / line, and the home page is open to crawlers.' },
|
|
138
|
+
},
|
|
139
|
+
engine: {
|
|
140
|
+
ru: { task: 'Найди в robots.txt правила, которые закрывают роботов, названных в строке «Сейчас», и убери для них Disallow: / (или весь их блок, если он только запрещает). Это роботы, которые достают страницу в момент ответа, а не обучающие: пока они закрыты, этот помощник не может сослаться на сайт. Если запрет поставлен осознанно, уточни у владельца.', verify: 'В /robots.txt для названных роботов нет запрета на весь сайт, и повторная проверка показывает этот помощник открытым.' },
|
|
141
|
+
en: { task: 'Find the robots.txt rules that block the crawlers named under Now and remove their Disallow: / (or their whole block if all it does is block). These crawlers fetch a page while answering, they are not training crawlers: while they are blocked, this assistant cannot cite the site. If the block is deliberate, check with the owner.', verify: '/robots.txt has no site-wide block for the named crawlers, and a fresh check shows this assistant as allowed.' },
|
|
142
|
+
},
|
|
143
|
+
'sitemap-foreign': {
|
|
144
|
+
ru: { task: 'Убери из карты сайта все адреса, которые ведут на другие домены: в sitemap.xml должны быть только страницы этого сайта на его основном адресе. Если карту собирает плагин или сборщик, поправь его настройку, а не файл руками, иначе адреса вернутся при следующей сборке.', verify: 'Все адреса в sitemap.xml начинаются с основного адреса сайта.' },
|
|
145
|
+
en: { task: 'Remove every URL that points at another domain from the sitemap: sitemap.xml should hold only this site\'s pages on its main address. If a plugin or the build generates the sitemap, fix its setting rather than the file by hand, or the URLs come back on the next build.', verify: 'Every URL in sitemap.xml starts with the site\'s main address.' },
|
|
146
|
+
},
|
|
147
|
+
'sitemap-health': {
|
|
148
|
+
ru: { task: 'Пройди по адресам из карты сайта и оставь в ней только те, что отвечают кодом 200. Адреса с переадресацией замени на конечные, мёртвые (404, 410, 5xx) убери. Если мёртвая страница должна существовать, не создавай её сама, а сообщи владельцу списком.', verify: 'Каждый адрес из sitemap.xml отвечает кодом 200 без переадресации.' },
|
|
149
|
+
en: { task: 'Go through the sitemap URLs and keep only those that answer 200. Replace redirecting URLs with their final address and drop dead ones (404, 410, 5xx). If a dead page ought to exist, do not invent it: list it for the owner.', verify: 'Every URL in sitemap.xml answers 200 without a redirect.' },
|
|
150
|
+
},
|
|
151
|
+
'api-catalog': {
|
|
152
|
+
ru: { task: 'Спроси владельца, есть ли у компании публичный API, который могут вызывать агенты. Если нет, ничего не делай: это необязательная строка, и выдумывать каталог ради галочки нельзя. Если есть, положи в /.well-known/api-catalog файл в формате linkset (RFC 9727) со ссылкой на описание API (OpenAPI) и документацию.', verify: 'Либо владелец подтвердил, что API нет, либо /.well-known/api-catalog отвечает кодом 200 с типом application/linkset+json и ссылки в нём открываются.' },
|
|
153
|
+
en: { task: 'Ask the owner whether the business has a public API that agents can call. If not, do nothing: this line is optional, and inventing a catalogue for the tick is wrong. If it does, serve /.well-known/api-catalog as a linkset (RFC 9727) pointing at the API description (OpenAPI) and the docs.', verify: 'Either the owner confirmed there is no API, or /.well-known/api-catalog answers 200 as application/linkset+json and its links open.' },
|
|
154
|
+
},
|
|
155
|
+
'title-all': {
|
|
156
|
+
ru: { task: 'Добавь тег title на страницы, где его нет (их адреса в пояснении к проверке). Заголовок до 60 знаков, свой у каждой страницы, говорит, что на ней, словами, которыми это ищут. Если страницы собраны из одного шаблона, почини шаблон: пусть он берёт заголовок из поля страницы, а не оставляет пустым.', verify: 'На каждой проверенной странице есть непустой title, и он не совпадает с title других страниц.' },
|
|
157
|
+
en: { task: 'Add a title tag to the pages that lack one (their paths are in the note to this check). Keep it under 60 characters, unique per page, saying what is on the page in the words people search with. If the pages share a template, fix the template so it takes the title from the page field instead of leaving it empty.', verify: 'Every sampled page has a non-empty title that no other page shares.' },
|
|
158
|
+
},
|
|
159
|
+
'description-all': {
|
|
160
|
+
ru: { task: 'Добавь meta description на страницы, где его нет: 120-160 знаков своими словами о том, что человек найдёт именно на этой странице. Бери смысл из текста страницы, обещаний, которых на ней нет, не пиши. Если описание пустое из-за шаблона, почини шаблон, чтобы он брал поле описания страницы.', verify: 'На каждой проверенной странице есть meta description длиной от 70 до 160 знаков, и описания не повторяются.' },
|
|
161
|
+
en: { task: 'Add a meta description to the pages that lack one: 120-160 characters in your own words on what a visitor finds on that page. Take the meaning from the page text and promise nothing the page does not have. If the template is why it is empty, fix the template to use the page description field.', verify: 'Every sampled page has a meta description of 70 to 160 characters, and no two are the same.' },
|
|
162
|
+
},
|
|
163
|
+
'h1-sample': {
|
|
164
|
+
ru: { task: 'На страницах, где больше одного H1, оставь один: главный заголовок страницы. Остальные H1 переведи в H2 или в обычный текст. Чаще всего лишний H1 сидит в шапке (логотип или название сайта) или в блоке шаблона, тогда правь шаблон, а не каждую страницу.', verify: 'На каждой проверенной странице ровно один H1.' },
|
|
165
|
+
en: { task: 'On pages with more than one H1, keep one: the main heading of the page. Turn the others into H2 or plain text. The extra H1 usually lives in the header (logo or site name) or in a template block; then fix the template, not every page.', verify: 'Every sampled page has exactly one H1.' },
|
|
166
|
+
},
|
|
167
|
+
'dup-titles': {
|
|
168
|
+
ru: { task: 'Найди страницы с одинаковым title (пример в строке «Сейчас») и дай каждой свой: что отличает эту страницу от соседних (город, услуга, модель, номер страницы списка). Если одинаковый title ставит шаблон, научи его брать заголовок страницы. Если страницы и правда дублируют друг друга, не переименовывай, а сообщи владельцу.', verify: 'Среди проверенных страниц нет двух с одинаковым title.' },
|
|
169
|
+
en: { task: 'Find the pages sharing one title (the example is under Now) and give each its own: what sets the page apart from its neighbours (city, service, model, list page number). If a template stamps the same title, make it use the page heading. If the pages really are duplicates of each other, do not rename them: tell the owner.', verify: 'No two sampled pages share a title.' },
|
|
170
|
+
},
|
|
171
|
+
'og-title': {
|
|
172
|
+
ru: { task: 'Добавь мета-тег og:title на страницы, где его нет. Значение бери из заголовка страницы (H1 или title без названия сайта). Лучше поставить его один раз в общем шаблоне, чтобы он заполнялся у всех страниц сам.', verify: 'На каждой проверенной странице есть <meta property="og:title"> с непустым значением, совпадающим по смыслу с заголовком страницы.' },
|
|
173
|
+
en: { task: 'Add an og:title meta tag to the pages that lack it. Take the value from the page heading (the H1, or the title without the site name). Best set it once in the shared template so every page gets it.', verify: 'Every sampled page has <meta property="og:title"> with a non-empty value matching the page heading.' },
|
|
174
|
+
},
|
|
175
|
+
'og-image': {
|
|
176
|
+
ru: { task: 'Добавь мета-тег og:image на страницы, где его нет: полный адрес картинки размером не меньше 1200 на 630 точек. Для страниц со своей главной картинкой бери её, для остальных одну общую (логотип на фоне или фото компании, которое уже есть на сайте). Новых изображений не рисуй и чужих не бери.', verify: 'На каждой проверенной странице есть og:image с полным адресом, и картинка по нему открывается с кодом 200.' },
|
|
177
|
+
en: { task: 'Add an og:image meta tag to the pages that lack it: the full URL of an image at least 1200 by 630 pixels. Pages with their own main image use it; the rest use one shared image (the logo on a background, or a company photo already on the site). Do not make up new images or take other people\'s.', verify: 'Every sampled page has og:image with a full URL, and the image opens with a 200.' },
|
|
178
|
+
},
|
|
179
|
+
'twitter-card': {
|
|
180
|
+
ru: { task: 'Добавь в общий шаблон мета-тег twitter:card со значением summary_large_image. Заголовок, описание и картинку X возьмёт из og-тегов, отдельно их дублировать не нужно, если og-теги уже стоят.', verify: 'В HTML главной есть <meta name="twitter:card" content="summary_large_image">.' },
|
|
181
|
+
en: { task: 'Add a twitter:card meta tag with the value summary_large_image to the shared template. X takes the title, description and image from the og tags, so they need no duplicates if the og tags are in place.', verify: 'The home page HTML has <meta name="twitter:card" content="summary_large_image">.' },
|
|
182
|
+
},
|
|
183
|
+
'schema-all': {
|
|
184
|
+
ru: { task: 'Добавь разметку JSON-LD на страницы, где её нет (адреса в пояснении). Тип выбирай по тому, что на странице на самом деле: статья (Article), услуга (Service), товар с ценой (Product), страница контактов (ContactPage), остальные хотя бы WebPage с BreadcrumbList. Значения бери только со страницы, ничего не дописывай от себя.', verify: 'На каждой проверенной странице есть хотя бы один блок JSON-LD, и валидатор schema.org не показывает ошибок.' },
|
|
185
|
+
en: { task: 'Add JSON-LD markup to the pages that lack it (paths in the note). Choose the type from what the page really is: an article (Article), a service (Service), a product with a price (Product), a contact page (ContactPage), and at least WebPage with BreadcrumbList for the rest. Take every value from the page itself, add nothing of your own.', verify: 'Every sampled page has at least one JSON-LD block, and the schema.org validator shows no errors.' },
|
|
186
|
+
},
|
|
187
|
+
'breadcrumbs-schema': {
|
|
188
|
+
ru: { task: 'Добавь на внутренние страницы разметку BreadcrumbList в JSON-LD: путь от главной до текущей страницы, каждый шаг с названием и полным адресом. Путь строй по настоящей структуре разделов сайта; если на странице уже видны хлебные крошки, разметка должна повторять их слово в слово.', verify: 'На внутренних проверенных страницах есть JSON-LD с типом BreadcrumbList, все адреса в нём открываются, валидатор не показывает ошибок.' },
|
|
189
|
+
en: { task: 'Add BreadcrumbList JSON-LD to the inner pages: the path from the home page to the current one, each step with a name and a full URL. Build the path from the site\'s real section structure; if the page already shows visible breadcrumbs, the markup must match them word for word.', verify: 'Inner sampled pages carry JSON-LD of type BreadcrumbList, every URL in it opens, and the validator shows no errors.' },
|
|
190
|
+
},
|
|
191
|
+
'org-schema': {
|
|
192
|
+
ru: { task: 'Добавь в общий шаблон (или хотя бы на главную) разметку организации в JSON-LD: Organization или более точный тип (LocalBusiness, RealEstateAgent и подобные), с названием, адресом сайта, логотипом, контактами и ссылками на профили в sameAs. Заполняй только тем, что есть на сайте или что подтвердил владелец, остальные поля оставь пустыми.', verify: 'На главной есть JSON-LD с типом Organization или его подтипом, в нём name и url, и валидатор не показывает ошибок.' },
|
|
193
|
+
en: { task: 'Add organisation JSON-LD to the shared template (or at least the home page): Organization or a more precise type (LocalBusiness, RealEstateAgent and the like), with the name, site address, logo, contacts and profile links in sameAs. Fill in only what is on the site or confirmed by the owner; leave the other fields out.', verify: 'The home page has JSON-LD of type Organization or a subtype, with name and url, and the validator shows no errors.' },
|
|
194
|
+
},
|
|
195
|
+
favicon: {
|
|
196
|
+
ru: { task: 'Добавь значок сайта: файл favicon (лучше SVG и запасной PNG 32 на 32 или ICO) из логотипа компании, и ссылку <link rel="icon"> на него в общем шаблоне. Логотип бери тот, что уже есть на сайте, новый не рисуй.', verify: 'В HTML главной есть <link rel="icon">, и файл по ссылке открывается с кодом 200.' },
|
|
197
|
+
en: { task: 'Add a site icon: a favicon file (SVG, plus a 32 by 32 PNG or an ICO as a fallback) made from the company logo, and a <link rel="icon"> to it in the shared template. Use the logo already on the site, do not draw a new one.', verify: 'The home page HTML has <link rel="icon">, and the file it points at opens with a 200.' },
|
|
198
|
+
},
|
|
199
|
+
'redirect-chain': {
|
|
200
|
+
ru: { task: 'Сделай так, чтобы адрес главной, который набирает человек, вёл на конечный адрес одной переадресацией, а не цепочкой (например http://, потом www, потом косая черта). Замени цепочку одним правилом 301 сразу на конечный адрес для каждого варианта.', verify: 'curl -sIL на адрес без www и без https показывает не больше одной переадресации до ответа 200.' },
|
|
201
|
+
en: { task: 'Make the home page address people type reach its final address in one redirect instead of a chain (http://, then www, then a trailing slash, say). Replace the chain with one 301 rule straight to the final address for each variant.', verify: 'curl -sIL on the address without www and without https shows at most one redirect before the 200.' },
|
|
202
|
+
},
|
|
203
|
+
resources: {
|
|
204
|
+
ru: { task: 'Сократи число скриптов, которые грузит главная: убери подключения неиспользуемых плагинов и виджетов, объедини мелкие файлы при сборке, несрочные скрипты подключи с defer или после загрузки. Ничего не удаляй вслепую: что нужно для форм, оплаты и счётчиков, оставь и перечисли владельцу, что убрано.', verify: 'Главная грузит не больше 15 файлов скриптов, а формы, меню и счётчики продолжают работать.' },
|
|
205
|
+
en: { task: 'Cut the number of scripts the home page loads: drop plugins and widgets nobody uses, bundle small files at build, load non-urgent scripts with defer or after load. Remove nothing blindly: keep whatever forms, payments and counters need, and list for the owner what you removed.', verify: 'The home page loads at most 15 script files, and forms, menus and counters still work.' },
|
|
206
|
+
},
|
|
207
|
+
xmlrpc: {
|
|
208
|
+
ru: { task: 'Закрой точку xmlrpc.php в WordPress: плагином безопасности, фильтром xmlrpc_enabled в теме или правилом сервера, которое отдаёт 403. Сначала уточни у владельца, не публикует ли он записи через мобильное приложение WordPress или Jetpack: им xmlrpc нужен.', verify: 'Запрос к /xmlrpc.php отвечает кодом 403 или 404.' },
|
|
209
|
+
en: { task: 'Close WordPress xmlrpc.php: with a security plugin, the xmlrpc_enabled filter in the theme, or a server rule that returns 403. First ask the owner whether they publish through the WordPress mobile app or Jetpack: those need xmlrpc.', verify: 'A request to /xmlrpc.php answers 403 or 404.' },
|
|
210
|
+
},
|
|
211
|
+
utility: {
|
|
212
|
+
ru: { task: 'Исключи из карты сайта служебные адреса: корзину, избранное, личный кабинет, страницы тегов и авторов, результаты поиска. Людям они пусть остаются доступны, убрать их нужно только из sitemap.xml (а страницам тегов и поиска ещё поставить noindex). Правь настройку плагина или сборщика карты, а не файл руками.', verify: 'В sitemap.xml нет адресов корзины, избранного, кабинета, тегов, авторов и поиска.' },
|
|
213
|
+
en: { task: 'Drop utility URLs from the sitemap: cart, wishlist, account, tag and author pages, search results. Leave them reachable for people; only take them out of sitemap.xml (and set noindex on tag and search pages). Change the sitemap plugin or build setting, not the file by hand.', verify: 'sitemap.xml lists no cart, wishlist, account, tag, author or search URLs.' },
|
|
214
|
+
},
|
|
215
|
+
viewport: {
|
|
216
|
+
ru: { task: 'Поставь в общий шаблон мета-тег <meta name="viewport" content="width=device-width, initial-scale=1">. Если в нём сейчас есть user-scalable=no или maximum-scale=1, убери их: запрет масштабирования мешает людям со слабым зрением и вредит мобильной выдаче.', verify: 'В HTML главной стоит viewport с width=device-width и без user-scalable=no и maximum-scale=1.' },
|
|
217
|
+
en: { task: 'Put <meta name="viewport" content="width=device-width, initial-scale=1"> in the shared template. If it currently has user-scalable=no or maximum-scale=1, remove them: blocking zoom hurts people with weak eyesight and mobile rankings.', verify: 'The home page HTML has a viewport with width=device-width and no user-scalable=no or maximum-scale=1.' },
|
|
218
|
+
},
|
|
219
|
+
'trust-profiles': {
|
|
220
|
+
ru: { task: 'Спроси владельца, где у компании есть настоящие профили: соцсети, каталоги, карты, отраслевые площадки. Впиши их адреса в поле sameAs разметки организации и поставь ссылки на них в подвал. Профили, которых нет или которые заброшены, не добавляй.', verify: 'В JSON-LD организации есть sameAs хотя бы с двумя адресами, и каждый открывает профиль именно этой компании.' },
|
|
221
|
+
en: { task: 'Ask the owner where the business really has profiles: social networks, directories, maps, industry sites. Put those addresses in the sameAs field of the organisation markup and link them from the footer. Do not add profiles that do not exist or are abandoned.', verify: 'The organisation JSON-LD has sameAs with at least two addresses, and each opens a profile of this very business.' },
|
|
222
|
+
},
|
|
223
|
+
'trust-about': {
|
|
224
|
+
ru: { task: 'Сделай страницу «О компании» (или «Команда») и поставь на неё ссылку в меню или подвал, чтобы она была видна со всех страниц. На странице: кто стоит за сайтом, чем занимается, с какого года, где находится, как связаться. Всё только со слов владельца или из того, что уже опубликовано; если страница уже есть, просто добавь ссылку.', verify: 'Со всех проверенных страниц есть ссылка на страницу о компании, и она открывается с кодом 200.' },
|
|
225
|
+
en: { task: 'Make an About (or Team) page and link it from the menu or footer so it is reachable from every page. On it: who is behind the site, what they do, since when, where, how to get in touch. Use only what the owner tells you or what is already published; if the page exists, just add the link.', verify: 'Every sampled page links to the about page, and it opens with a 200.' },
|
|
226
|
+
},
|
|
227
|
+
'trust-policies': {
|
|
228
|
+
ru: { task: 'Поставь в подвал ссылки на политику конфиденциальности и на условия (оферту или пользовательское соглашение). Если документов нет, не сочиняй их: юридический текст за владельцем, сообщи ему, каких страниц не хватает. Если документы есть, но не связаны с сайтом, просто добавь ссылки.', verify: 'В подвале каждой страницы есть ссылки на политику конфиденциальности и на условия, и обе открываются с кодом 200.' },
|
|
229
|
+
en: { task: 'Link the privacy policy and the terms from the footer. If the documents do not exist, do not write them yourself: legal text is the owner\'s call, so tell them which pages are missing. If they exist but are not linked, just add the links.', verify: 'Every page footer links to a privacy policy and to terms, and both open with a 200.' },
|
|
230
|
+
},
|
|
231
|
+
'conv-contact-path': {
|
|
232
|
+
ru: { task: 'Сделай так, чтобы с любой страницы можно было связаться: ссылка «Контакты» в меню или подвале, телефон или мессенджер в шапке, либо короткая форма. Используй только те контакты, которые уже есть на сайте или которые дал владелец.', verify: 'На каждой проверенной странице есть форма, ссылка на контакты или прямой контакт (tel:, mailto:, мессенджер).' },
|
|
233
|
+
en: { task: 'Make it possible to get in touch from any page: a Contact link in the menu or footer, a phone or messenger in the header, or a short form. Use only contacts already on the site or given by the owner.', verify: 'Every sampled page has a form, a contact link or a direct contact (tel:, mailto:, a messenger).' },
|
|
234
|
+
},
|
|
235
|
+
'conv-form-depth': {
|
|
236
|
+
ru: { task: 'Подними форму заявки выше на длинных страницах: либо перенеси её в первую треть, либо поставь вверху кнопку, которая прокручивает к форме. Сами поля и текст формы не меняй.', verify: 'На проверенных страницах форма или кнопка, ведущая к ней, видна в первой половине страницы, в том числе на телефоне.' },
|
|
237
|
+
en: { task: 'Move the enquiry form higher on long pages: either into the first third, or add a button near the top that scrolls to it. Do not change the form fields or wording.', verify: 'On the sampled pages the form, or a button leading to it, sits in the first half of the page, on a phone too.' },
|
|
238
|
+
},
|
|
239
|
+
'conv-form-fields': {
|
|
240
|
+
ru: { task: 'Сократи форму заявки до четырёх полей или меньше: имя и один способ связи (телефон или мессенджер, на выбор), остальное необязательным или убрать. Какие поля нужны для работы, решает владелец: перечисли ему, что предлагаешь убрать, и убирай после его ответа.', verify: 'В форме не больше четырёх полей, и заявка с заполненными обязательными полями доходит до владельца.' },
|
|
241
|
+
en: { task: 'Cut the enquiry form to four fields or fewer: a name and one way to reach them (phone or messenger, their choice), the rest optional or gone. The owner decides which fields the business needs: list what you propose to drop and drop it once they agree.', verify: 'The form has at most four fields, and a submission with the required fields reaches the owner.' },
|
|
242
|
+
},
|
|
243
|
+
sections: {
|
|
244
|
+
ru: { task: 'Разбей длинные страницы на разделы: каждый начинается заголовком H2, который звучит как вопрос или тема раздела. На странице должно быть не меньше трёх таких разделов. Ради объёма ничего не дописывай, только структурируй то, что есть.', verify: 'На каждой проверенной странице не меньше трёх заголовков H2, и по ним понятно, о чём страница, без чтения текста.' },
|
|
245
|
+
en: { task: 'Split the long pages into sections, each opening with an H2 that reads as the question or topic of that section. A page needs at least three. Add nothing for length, only structure what is there.', verify: 'Every sampled page has at least three H2 headings, and they alone tell what the page is about.' },
|
|
246
|
+
},
|
|
247
|
+
tables: {
|
|
248
|
+
ru: { task: 'Там, где на странице есть цифры, которые сравнивают (цены, сроки, площади, условия), собери их в таблицу с подписанными столбцами. Строй её только из данных, которые уже есть на странице или у владельца.', verify: 'На страницах со сравниваемыми цифрами есть хотя бы одна настоящая таблица (тег table), а не картинка и не список.' },
|
|
249
|
+
en: { task: 'Where a page has figures that get compared (prices, timings, areas, terms), put them in a table with labelled columns. Build it only from data already on the page or held by the owner.', verify: 'Pages with comparable figures carry at least one real table (a table tag), not an image and not a list.' },
|
|
250
|
+
},
|
|
117
251
|
};
|
|
118
252
|
|
|
119
253
|
const withLang = (v, lang) => v[lang === 'ru' ? 'ru' : 'en'];
|
|
@@ -202,10 +336,13 @@ function whereLine(id, platform, lang) {
|
|
|
202
336
|
return `**${head}:** ${Object.entries(map).map(([k, v]) => `${PLATFORM_LABEL[k]}, ${v[lang]}`).join('; ')}`;
|
|
203
337
|
}
|
|
204
338
|
|
|
339
|
+
/** Строки «помощник может читать сайт» идут по одной на помощника (engine-chatgpt и т.д.), задание у них общее. */
|
|
340
|
+
const promptKey = (id) => (String(id || '').startsWith('engine-') ? 'engine' : id);
|
|
341
|
+
|
|
205
342
|
export function agentPrompt(check, opts = {}) {
|
|
206
343
|
const lang = opts.lang === 'ru' ? 'ru' : 'en';
|
|
207
344
|
const h = HEAD[lang];
|
|
208
|
-
const own = P[check.id] ? withLang(P[check.id], lang) : null;
|
|
345
|
+
const own = P[promptKey(check.id)] ? withLang(P[promptKey(check.id)], lang) : null;
|
|
209
346
|
const rule = FIX_ACTIONS[check.id];
|
|
210
347
|
const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
|
|
211
348
|
|
|
@@ -230,7 +367,7 @@ export function agentPrompts(audit, opts = {}) {
|
|
|
230
367
|
const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
|
|
231
368
|
const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
|
|
232
369
|
const platform = opts.platform || detectPlatform(audit);
|
|
233
|
-
return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[c.id]), text: agentPrompt(c, { lang, platform }) }));
|
|
370
|
+
return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)]), text: agentPrompt(c, { lang, platform }) }));
|
|
234
371
|
}
|
|
235
372
|
|
|
236
373
|
/** Файл, который отдаётся клиенту вместе с отчётом. */
|
|
@@ -276,6 +413,8 @@ const FIRST_FIX_BY_PROMPT = {
|
|
|
276
413
|
'answer-first-missing': 'answer-first',
|
|
277
414
|
'sources-missing': 'sources',
|
|
278
415
|
'thin-pages': 'thin',
|
|
416
|
+
'few-h2': 'sections',
|
|
417
|
+
'no-tables': 'tables',
|
|
279
418
|
};
|
|
280
419
|
|
|
281
420
|
const FIRST_FIX_OWN = {
|
|
@@ -299,14 +438,6 @@ const FIRST_FIX_OWN = {
|
|
|
299
438
|
ru: { task: 'Добавь на страницы статей и записей разметку Article или BlogPosting в JSON-LD: заголовок, дата публикации, дата изменения, автор, издатель. Бери данные со страницы, ничего не придумывай.', verify: 'На странице статьи есть JSON-LD с типом Article или BlogPosting, и проверка разметки не показывает ошибок.' },
|
|
300
439
|
en: { task: 'Add Article or BlogPosting markup in JSON-LD to the article pages: headline, date published, date modified, author, publisher. Take the data from the page, invent nothing.', verify: 'An article page carries JSON-LD of type Article or BlogPosting and the markup validator shows no errors.' },
|
|
301
440
|
},
|
|
302
|
-
'few-h2': {
|
|
303
|
-
ru: { task: 'Разбей длинные страницы на разделы: каждый начинается заголовком H2, который звучит как вопрос или тема раздела. На странице должно быть не меньше трёх таких разделов. Ради объёма ничего не дописывай, только структурируй то, что есть.', verify: 'На каждой проверенной странице не меньше трёх заголовков H2, и по ним понятно, о чём страница, без чтения текста.' },
|
|
304
|
-
en: { task: 'Split the long pages into sections, each opening with an H2 that reads as the question or topic of that section. A page needs at least three. Add nothing for length, only structure what is there.', verify: 'Every sampled page has at least three H2 headings, and they alone tell what the page is about.' },
|
|
305
|
-
},
|
|
306
|
-
'no-tables': {
|
|
307
|
-
ru: { task: 'Там, где на странице есть цифры, которые сравнивают (цены, сроки, площади, условия), собери их в таблицу с подписанными столбцами. Строй её только из данных, которые уже есть на странице или у владельца.', verify: 'На страницах со сравниваемыми цифрами есть хотя бы одна настоящая таблица (тег table), а не картинка и не список.' },
|
|
308
|
-
en: { task: 'Where a page has figures that get compared (prices, timings, areas, terms), put them in a table with labelled columns. Build it only from data already on the page or held by the owner.', verify: 'Pages with comparable figures carry at least one real table (a table tag), not an image and not a list.' },
|
|
309
|
-
},
|
|
310
441
|
'sitemap-no-lastmod': {
|
|
311
442
|
ru: { task: 'Проставь в карте сайта (sitemap.xml) каждой странице поле lastmod с настоящей датой последнего изменения содержимого. Дату сборки или сегодняшнее число во все строки не ставь: это хуже, чем пустое поле.', verify: 'У каждого url в sitemap.xml есть lastmod, даты различаются между страницами и совпадают с датой изменения на самой странице.' },
|
|
312
443
|
en: { task: 'Give every page in sitemap.xml a lastmod with the real date its content last changed. Do not stamp the build date or today on every line: that is worse than leaving it empty.', verify: 'Every url in sitemap.xml has a lastmod, the dates differ between pages and match the modified date on the page itself.' },
|
|
@@ -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всё сходится');
|
package/src/test-prompts.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
5
5
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
6
|
+
import { localiseChecks } from './i18n.mjs';
|
|
6
7
|
|
|
7
8
|
let bad = 0;
|
|
8
9
|
const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
|
|
@@ -20,7 +21,7 @@ ok('в задании есть запрет выдумывать', one.includes(
|
|
|
20
21
|
// Даже там, где своего текста нет, все три части на месте
|
|
21
22
|
const generic = agentPrompt(c('xmlrpc', 'warn', 'xmlrpc.php', 'открыт'), { lang: 'ru' });
|
|
22
23
|
ok('общее задание тоже с проверкой', generic.includes('**Как проверить:**'));
|
|
23
|
-
ok('и
|
|
24
|
+
ok('и со своим действием', generic.includes('Закрой точку xmlrpc.php'));
|
|
24
25
|
|
|
25
26
|
// Порядок: сначала провалы, потом предупреждения
|
|
26
27
|
const list = agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
|
|
@@ -108,5 +109,34 @@ ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.
|
|
|
108
109
|
ok('незнакомый язык падает в английский', firstFixParts({ id: 'llms-missing', text: 'x' }, { lang: 'de' }).labels.now === 'Now');
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/*
|
|
113
|
+
* У каждой проверки, которая может выйти находкой, своё задание. 01.10.2026 в первых настоящих
|
|
114
|
+
* выдачах (dial93.ru, bristolpropertycentre.co.uk) «Задача» у части проверок просто повторяла
|
|
115
|
+
* название, а «Как проверить» была общей фразой «прогони проверку заново». Список проверок берём
|
|
116
|
+
* прямо из collect.mjs, чтобы новая проверка без задания роняла этот тест.
|
|
117
|
+
*/
|
|
118
|
+
{
|
|
119
|
+
const src = readFileSync(new URL('./collect.mjs', import.meta.url), 'utf8');
|
|
120
|
+
const rows = [...src.matchAll(/row\(\s*['`]([^'`]+)['`]\s*,\s*'([a-z]+)'\s*,\s*['`"]([^'`"]*)['`"]/g)]
|
|
121
|
+
.map((m) => ({ id: m[1].replace('${e.id}', 'chatgpt'), label: m[3].replace('${e.label}', 'ChatGPT') }));
|
|
122
|
+
rows.push({ id: 'js-content', label: 'Text visible without running JavaScript' });
|
|
123
|
+
// Эти строки выходят только со статусом na (справка или осознанный выбор), находкой не бывают.
|
|
124
|
+
const NEVER_OPEN = new Set(['cms', 'robots-ai', 'howto-schema', 'product-schema', 'hreflang', 'links-profile']);
|
|
125
|
+
ok('список проверок из collect.mjs прочитан', rows.length > 40);
|
|
126
|
+
const field = (text, name) => (text.match(new RegExp(`\\*\\*${name}:\\*\\* (.*)`)) || [])[1] || '';
|
|
127
|
+
const empty = [];
|
|
128
|
+
for (const r of rows.filter((x) => !NEVER_OPEN.has(x.id))) {
|
|
129
|
+
for (const lang of ['ru', 'en']) {
|
|
130
|
+
const [chk] = lang === 'ru' ? localiseChecks([c(r.id, 'warn', r.label, 'x')], 'ru') : [c(r.id, 'warn', r.label, 'x')];
|
|
131
|
+
const t = agentPrompt(chk, { lang });
|
|
132
|
+
const task = field(t, lang === 'ru' ? 'Задача' : 'Task').trim();
|
|
133
|
+
const verify = field(t, lang === 'ru' ? 'Как проверить' : 'How to check').trim();
|
|
134
|
+
const generic = lang === 'ru' ? /^Прогони проверку сайта заново/ : /^Run the site check again/;
|
|
135
|
+
if (!task || task === chk.label || task === r.label || task.length < 60 || !verify || generic.test(verify)) empty.push(`${r.id}/${lang}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
is('у каждой проверки своё задание и своя проверка, не название', empty, []);
|
|
139
|
+
}
|
|
140
|
+
|
|
111
141
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
112
142
|
console.log('\nвсе тесты заданий прошли');
|
|
@@ -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всё сходится');
|
package/src/visibility.mjs
CHANGED
|
@@ -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(/&/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
|
-
|
|
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
|
-
|
|
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 =
|
|
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();
|