@operstack/audit 0.32.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +2 -2
- package/src/collect.mjs +117 -31
- package/src/i18n.mjs +1 -1
- package/src/prospect.mjs +30 -6
- package/src/test-crawl-links.mjs +87 -0
- package/src/test-prospect-count.mjs +12 -0
- package/src/test-redirect-host.mjs +63 -0
- package/src/test-ru-report.mjs +95 -0
- package/src/visibility.mjs +103 -3
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/oper-stack/brand/main/og-audit.png" alt="OperStack audit-generator" width="100%">
|
|
2
|
+
|
|
1
3
|
# @operstack/audit
|
|
2
4
|
|
|
3
5
|
The SEO, AEO and GEO audit generator behind the OperStack audit service. It collects the public signals of a site into one JSON file, leaves the narrative to an analyst or an agent, and renders a print-ready report.
|
|
@@ -90,10 +92,10 @@ Technically: `draft` fills the narrative fields (`summary`, `client.subject`, `o
|
|
|
90
92
|
**If you do not edit the site yourself.** Most people who read an audit now fix their site by telling an AI assistant what to do. This turns every finding into a task written for that assistant: copy it, paste it into Cursor or Claude Code, and it does the work. Each task says what the state is today, what to change, and how you will know it is finished, so the assistant cannot wander off and cannot quietly invent facts about your business.
|
|
91
93
|
|
|
92
94
|
```
|
|
93
|
-
npx @operstack/audit prompts audit.json --lang en
|
|
95
|
+
npx @operstack/audit prompts audit.json --lang en
|
|
94
96
|
```
|
|
95
97
|
|
|
96
|
-
Technically: every open check is rendered as a Markdown block with `Now` (the measured value), `Task` (the action and its boundaries) and `How to check` (the acceptance test), plus a standing rule against inventing business facts. Twenty checks carry text written for that specific finding; the rest fall back to the Fix action with a generic acceptance test. `--limit 3` is
|
|
98
|
+
Technically: every open check is rendered as a Markdown block with `Now` (the measured value), `Task` (the action and its boundaries) and `How to check` (the acceptance test), plus a standing rule against inventing business facts. Twenty checks carry text written for that specific finding; the rest fall back to the Fix action with a generic acceptance test. Every open finding becomes a task, and that is the free behaviour: no account, no key, no trimmed preview. `--limit 3` is there when you want only the three tasks that move the score most. The paid audit sells the reading and the work, not the list. `renderFixChecklist(plan, { withPrompts: true })` embeds the same tasks in the executor checklist.
|
|
97
99
|
|
|
98
100
|
## The Fix package: turning findings into agreed work
|
|
99
101
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs",
|
|
32
|
+
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs",
|
|
33
33
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
34
34
|
"verify:live": "node src/verify-live.mjs"
|
|
35
35
|
},
|
package/src/collect.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
|
|
10
10
|
import { parse } from 'node-html-parser';
|
|
11
11
|
import { localiseChecks } from './i18n.mjs';
|
|
12
|
-
import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
12
|
+
import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows } from './visibility.mjs';
|
|
13
13
|
|
|
14
14
|
/** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
|
|
15
15
|
* в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
|
|
@@ -104,18 +104,47 @@ export function crawlDelayFrom(robotsText, capMs = 3000) {
|
|
|
104
104
|
return { ms: Math.min(asked, capMs), asked, capped: asked > capMs };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
async function follow(url, max = 5) {
|
|
107
|
+
async function follow(url, max = 5, opts = {}) {
|
|
108
108
|
const hops = [];
|
|
109
109
|
let cur = url;
|
|
110
110
|
for (let i = 0; i < max; i++) {
|
|
111
|
-
const r = await get(cur);
|
|
111
|
+
const r = await get(cur, opts);
|
|
112
112
|
hops.push({ url: cur, status: r.status, location: r.location });
|
|
113
113
|
if (r.status >= 300 && r.status < 400 && r.location) { cur = new URL(r.location, cur).href; continue; }
|
|
114
114
|
return { final: cur, response: r, hops };
|
|
115
115
|
}
|
|
116
|
-
return { final: cur, response: await get(cur), hops };
|
|
116
|
+
return { final: cur, response: await get(cur, opts), hops };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** Служебный файл с переходом по переадресации: robots.txt, карта сайта, llms.txt. Сайт, у
|
|
120
|
+
* которого голый домен отвечает 301 на www, отдаёт их только по конечному адресу, а get() ходит
|
|
121
|
+
* с redirect: 'manual' и видел бы один 301. Возвращает ответ конечного адреса, url в нём тоже конечный. */
|
|
122
|
+
async function getFollowed(url, opts = {}) {
|
|
123
|
+
const f = await follow(url, 5, opts);
|
|
124
|
+
return { ...f.response, url: f.final };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* Какие правовые страницы стоят за ссылкой. Смотрим каждую часть пути, а не только начало после
|
|
129
|
+
* косой черты: русский сайт пишет href="politika-konfidenczialnosti/" без неё, и до 30.09.2026
|
|
130
|
+
* агентство из Екатеринбурга получало красное «ни политики, ни условий нет» при живой политике.
|
|
131
|
+
* Оферта проверяется целым словом: испанское /ofertas/ это акции, а не условия.
|
|
132
|
+
*/
|
|
133
|
+
const POLICY_SLUGS = [
|
|
134
|
+
['privacy', /^(privacy|politika|[a-z-]*konfiden|политика|[а-я-]*конфиденциальн)/],
|
|
135
|
+
['terms', /^(terms|usloviya|oferta(?![a-z])|polzovatelskoe-soglashenie|условия|оферта|пользовательское-соглашение)/],
|
|
136
|
+
['consent', /^(soglasie|согласие)/],
|
|
137
|
+
['legal', /^legal/],
|
|
138
|
+
['cookie', /^cookie/],
|
|
139
|
+
['disclaimer', /^disclaimer/],
|
|
140
|
+
];
|
|
141
|
+
export function policyKinds(href) {
|
|
142
|
+
const h = String(href || '').toLowerCase();
|
|
143
|
+
if (/^(mailto|tel|javascript|data):/.test(h) || h.startsWith('#')) return [];
|
|
144
|
+
let path = h.replace(/^([a-z][a-z0-9+.-]*:)?\/\/[^/]+/, '').split(/[?#]/)[0];
|
|
145
|
+
try { path = decodeURIComponent(path); } catch { /* битая кодировка: читаем как есть */ }
|
|
146
|
+
return path.split('/').filter(Boolean).flatMap((seg) => { const k = POLICY_SLUGS.find(([, re]) => re.test(seg)); return k ? [k[0]] : []; });
|
|
147
|
+
}
|
|
119
148
|
const row = (id, group, label, status, value, comment = '') => ({ id, group, label, status, value, comment });
|
|
120
149
|
|
|
121
150
|
/**
|
|
@@ -209,7 +238,7 @@ function analysePage(url, html) {
|
|
|
209
238
|
sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
|
|
210
239
|
sameAsUrls: [...new Set(sameAs)],
|
|
211
240
|
aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
|
|
212
|
-
policyPages: [...new Set(hrefsLower.
|
|
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/prospect.mjs
CHANGED
|
@@ -85,9 +85,24 @@ export const OWNER_LINES = {
|
|
|
85
85
|
en: 'On a phone your page cannot be pinched to zoom: the code forbids it.',
|
|
86
86
|
ru: 'С телефона страницу нельзя увеличить пальцами, масштабирование запрещено в коде.',
|
|
87
87
|
},
|
|
88
|
+
/*
|
|
89
|
+
* Фраза от числа (задание Максима 26.09.2026): «Not one page…» стояло и при «5 of 8», то есть
|
|
90
|
+
* письмо говорило неправду про сайт, где половина страниц в порядке. Число берётся из самой
|
|
91
|
+
* проверки («5 of 8 sampled page(s) open…»).
|
|
92
|
+
*/
|
|
88
93
|
'answer-first': {
|
|
89
|
-
en:
|
|
90
|
-
|
|
94
|
+
en: (c) => {
|
|
95
|
+
const [have, of] = countOf(c);
|
|
96
|
+
return have > 0 && of > have
|
|
97
|
+
? `Only ${have} of your ${of} checked pages open with a short direct answer, and that opening is exactly what gets quoted.`
|
|
98
|
+
: 'Not one page opens with a short direct answer, and that opening is exactly what gets quoted.';
|
|
99
|
+
},
|
|
100
|
+
ru: (c) => {
|
|
101
|
+
const [have, of] = countOf(c);
|
|
102
|
+
return have > 0 && of > have
|
|
103
|
+
? `Короткий прямой ответ в первом абзаце есть только на ${have} из ${of} проверенных страниц, а цитируют именно его.`
|
|
104
|
+
: 'Ни на одной странице нет короткого прямого ответа в первом абзаце, а цитируют именно его.';
|
|
105
|
+
},
|
|
91
106
|
},
|
|
92
107
|
'tables': {
|
|
93
108
|
en: 'Your pages have no tables, and a table is the easiest thing for an assistant to lift and quote.',
|
|
@@ -164,8 +179,17 @@ const LEAD_ORDER = [
|
|
|
164
179
|
'conv-form-fields', 'conv-contact-path', 'agent-markdown', 'h1', 'dates', 'utility',
|
|
165
180
|
];
|
|
166
181
|
|
|
182
|
+
/** «5 of 8 sampled page(s)…» в находке: сколько страниц в порядке и сколько проверено. */
|
|
183
|
+
function countOf(check) {
|
|
184
|
+
const m = String(check?.value || '').match(/(\d+)\s+(?:of|из)\s+(\d+)/);
|
|
185
|
+
return m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
/** Фраза владельцу на нужном языке. Нет перевода, значит нет фразы, и печатается сама находка. */
|
|
168
|
-
const line = (id, lang = 'en'
|
|
189
|
+
const line = (id, lang = 'en', check = null) => {
|
|
190
|
+
const say = (OWNER_LINES[id] || {})[lang === 'ru' ? 'ru' : 'en'];
|
|
191
|
+
return (typeof say === 'function' ? say(check) : say) || '';
|
|
192
|
+
};
|
|
169
193
|
|
|
170
194
|
/** Вес области при подсчёте одной общей оценки. Считаем только то, что правда измерено. */
|
|
171
195
|
const WEIGHT = {
|
|
@@ -207,15 +231,15 @@ export function worstFindings(audit, limit = 3, lang = 'en') {
|
|
|
207
231
|
const s = (SEVERITY[a.status] ?? 2) - (SEVERITY[b.status] ?? 2);
|
|
208
232
|
if (s) return s;
|
|
209
233
|
// При равной тяжести вперёд идёт то, для чего есть фраза владельцу: она и попадёт в письмо.
|
|
210
|
-
return (line(b.id, lang) ? 1 : 0) - (line(a.id, lang) ? 1 : 0);
|
|
234
|
+
return (line(b.id, lang, b) ? 1 : 0) - (line(a.id, lang, a) ? 1 : 0);
|
|
211
235
|
});
|
|
212
236
|
return open.slice(0, limit).map((c) => ({
|
|
213
237
|
id: c.id,
|
|
214
238
|
status: c.status,
|
|
215
239
|
label: c.label,
|
|
216
240
|
value: c.value,
|
|
217
|
-
say: line(c.id, lang) || `${c.label}: ${c.value}`,
|
|
218
|
-
translated: Boolean(line(c.id, lang)),
|
|
241
|
+
say: line(c.id, lang, c) || `${c.label}: ${c.value}`,
|
|
242
|
+
translated: Boolean(line(c.id, lang, c)),
|
|
219
243
|
}));
|
|
220
244
|
}
|
|
221
245
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Сайт без карты сайта читается по ссылкам, а не одной главной.
|
|
4
|
+
*
|
|
5
|
+
* 30.09.2026 elitelivingrealty.com и solohomes.co.uk (на www нет ни robots.txt, ни карты) дали
|
|
6
|
+
* в отчёте выборку из одной главной. Здесь: разбор ссылок и правил robots.txt отдельно, потом
|
|
7
|
+
* живой сервер без карты, где страницы находятся только по ссылкам, на двух уровнях.
|
|
8
|
+
*/
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
import { lookup } from 'node:dns/promises';
|
|
11
|
+
import { internalLinks, robotsAllows } from './visibility.mjs';
|
|
12
|
+
import { collect } from './collect.mjs';
|
|
13
|
+
import { checkVisibility } from './visibility.mjs';
|
|
14
|
+
|
|
15
|
+
let bad = 0;
|
|
16
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
17
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
18
|
+
|
|
19
|
+
// ---- разбор ссылок
|
|
20
|
+
{
|
|
21
|
+
const html = `<a href="/buy/">Buy</a><a href="/buy">dup</a><a href="https://www.x.com/rent/?page=2#top">Rent</a>
|
|
22
|
+
<a href="https://x.com/sell/">apex</a><a href="https://other.com/">other</a><a href="mailto:a@x.com">m</a>
|
|
23
|
+
<a href="tel:+1">t</a><a href="#top">anchor</a><a href="/brochure.pdf">pdf</a><a href="/about.html">about</a>
|
|
24
|
+
<a href="/cart/">cart</a><a href="/tag/villas/">tag</a><a href="/x/" rel="nofollow">nf</a><a href="javascript:void(0)">js</a>`;
|
|
25
|
+
is('свои страницы, без повторов, файлов и служебных', internalLinks(html, 'https://www.x.com/'),
|
|
26
|
+
['https://www.x.com/buy/', 'https://www.x.com/rent/', 'https://x.com/sell/', 'https://www.x.com/about.html']);
|
|
27
|
+
is('относительные ссылки от <base>', internalLinks('<base href="https://www.x.com/en/"><a href="team/">t</a>', 'https://www.x.com/'), ['https://www.x.com/en/team/']);
|
|
28
|
+
is('битый html не роняет разбор', internalLinks(undefined, 'https://x.com/'), []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- robots.txt
|
|
32
|
+
{
|
|
33
|
+
const allow = robotsAllows('User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /private/\nDisallow: /*.php$\nAllow: /private/open/\n');
|
|
34
|
+
ok('обычная страница разрешена', allow('https://x.com/buy/'));
|
|
35
|
+
ok('закрытый раздел нет', !allow('https://x.com/private/a/'));
|
|
36
|
+
ok('Allow длиннее Disallow побеждает', allow('https://x.com/private/open/a/'));
|
|
37
|
+
ok('звёздочка и доллар', !allow('https://x.com/a/b.php') && allow('https://x.com/a/b.php5'));
|
|
38
|
+
ok('блок чужого робота не применяется', allow('https://x.com/'));
|
|
39
|
+
ok('пустой robots.txt разрешает всё', robotsAllows('')('https://x.com/a/'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- живой сервер без карты сайта
|
|
43
|
+
let dnsOk = true;
|
|
44
|
+
try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
45
|
+
if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(bad ? 1 : 0); }
|
|
46
|
+
|
|
47
|
+
const LINKS = {
|
|
48
|
+
'/': ['/buy/', '/rent/', '/private/', '/contact/', 'https://elsewhere.example/'],
|
|
49
|
+
'/buy/': ['/buy/villas/', '/buy/flats/', '/'],
|
|
50
|
+
'/rent/': ['/rent/long-term/'],
|
|
51
|
+
'/contact/': [], '/buy/villas/': [], '/buy/flats/': [], '/rent/long-term/': [], '/private/': [],
|
|
52
|
+
};
|
|
53
|
+
const page = (path) => `<!doctype html><html lang="en"><head><title>Page ${path}</title>
|
|
54
|
+
<meta name="description" content="A page description long enough not to count as empty for the check."></head>
|
|
55
|
+
<body><main><h1>Page ${path}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}
|
|
56
|
+
<nav>${LINKS[path].map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
|
|
57
|
+
const hits = [];
|
|
58
|
+
const server = createServer((req, res) => {
|
|
59
|
+
hits.push(req.url);
|
|
60
|
+
if (req.url === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end('User-agent: *\nDisallow: /private/\n'); }
|
|
61
|
+
if (LINKS[req.url]) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(req.url)); }
|
|
62
|
+
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
|
|
63
|
+
});
|
|
64
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
65
|
+
const base = `http://lvh.me:${server.address().port}`;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const a = await collect(`${base}/`, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
69
|
+
const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
|
|
70
|
+
ok('карты нет, и отчёт это говорит', (a.checks.find((c) => c.id === 'sitemap') || {}).status === 'bad');
|
|
71
|
+
is('прочитаны все открытые страницы по ссылкам, двумя уровнями', [...read].sort(), ['/', '/buy/', '/buy/flats/', '/buy/villas/', '/contact/', '/rent/', '/rent/long-term/']);
|
|
72
|
+
ok('закрытое в robots.txt не читается', !hits.includes('/private/'));
|
|
73
|
+
ok('страница контактов идёт раньше остальных ссылок', read.indexOf('/contact/') < read.indexOf('/buy/'));
|
|
74
|
+
is('счёт найденных по ссылкам', a.reading.fromLinks, 6);
|
|
75
|
+
ok('в источниках сказано, откуда страницы', /found by following links/.test(a.sources[0].detail));
|
|
76
|
+
|
|
77
|
+
const few = await collect(`${base}/`, { pages: 3, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
78
|
+
is('предел страниц соблюдается', few.sample.length, 3);
|
|
79
|
+
|
|
80
|
+
const v = await checkVisibility(`${base}/`, { lang: 'en', samplePages: 5, budgetMs: 8000 });
|
|
81
|
+
ok('бесплатная проверка прочитала сайт', v.ok);
|
|
82
|
+
const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
|
|
83
|
+
ok(`бесплатная проверка взяла страницы со ссылок главной (${vRead.join(', ')})`, vRead.length >= 4 && vRead.includes('/buy/') && !vRead.includes('/private/'));
|
|
84
|
+
} finally { server.close(); }
|
|
85
|
+
|
|
86
|
+
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
87
|
+
console.log('\nвсё сходится');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Фраза про короткий ответ зависит от числа страниц (задание 26.09.2026): «Not one page» только при нуле.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { prospectRow } from './prospect.mjs';
|
|
4
|
+
const audit = (value, status) => ({ meta: { host: 'a.test' }, scores: {}, checks: [{ id: 'answer-first', status, label: 'Answer-first opening paragraph', value }] });
|
|
5
|
+
const warn = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'));
|
|
6
|
+
assert.equal(warn.say, 'Only 5 of your 8 checked pages open with a short direct answer, and that opening is exactly what gets quoted.');
|
|
7
|
+
const bad = prospectRow(audit('0 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'bad'));
|
|
8
|
+
assert.match(bad.say, /^Not one page opens/);
|
|
9
|
+
const ru = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'), { lang: 'ru' });
|
|
10
|
+
assert.equal(ru.say, 'Короткий прямой ответ в первом абзаце есть только на 5 из 8 проверенных страниц, а цитируют именно его.');
|
|
11
|
+
assert.match(prospectRow(audit('', 'bad')).say, /^Not one page opens/, 'без числа прежняя фраза');
|
|
12
|
+
console.log('test-prospect-count: ok');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Голый домен, который отвечает 301 на www, должен читаться так же, как сам www.
|
|
4
|
+
*
|
|
5
|
+
* 30.09.2026 bristolpropertycentre.co.uk получил в отчёте «no sitemap found at the usual paths» и
|
|
6
|
+
* «1 pages sampled»: robots.txt и карта сайта брались с введённого адреса без перехода по 301, а
|
|
7
|
+
* выборка в бесплатной проверке отсеивала страницы карты, потому что они на www. Тот же сайт,
|
|
8
|
+
* введённый с www, давал сто страниц. Здесь это держится на живом сервере: lvh.me и www.lvh.me оба
|
|
9
|
+
* указывают на 127.0.0.1, и сервер по заголовку Host отправляет голый домен на www.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { lookup } from 'node:dns/promises';
|
|
13
|
+
import { collect } from './collect.mjs';
|
|
14
|
+
import { checkVisibility } from './visibility.mjs';
|
|
15
|
+
|
|
16
|
+
let bad = 0;
|
|
17
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
18
|
+
|
|
19
|
+
let dnsOk = true;
|
|
20
|
+
try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1' && (await lookup('www.lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
21
|
+
if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
|
|
22
|
+
|
|
23
|
+
const PAGES = ['/', '/buy/', '/rent/', '/sell/', '/about/', '/contact/'];
|
|
24
|
+
const page = (title) => `<!doctype html><html lang="en"><head><title>${title}</title>
|
|
25
|
+
<meta name="description" content="A page description long enough not to count as empty for the check.">
|
|
26
|
+
</head><body><main><h1>${title}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}</main></body></html>`;
|
|
27
|
+
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
const host = (req.headers.host || '').toLowerCase();
|
|
30
|
+
const www = `http://www.${host.replace(/^www\./, '')}`;
|
|
31
|
+
if (!host.startsWith('www.')) { res.writeHead(301, { location: `${www}${req.url}` }); return res.end(); }
|
|
32
|
+
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
|
|
33
|
+
if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
|
|
34
|
+
if (req.url === '/llms.txt') return send(200, 'text/plain', `# Site\n\n- [Buy](${www}/buy/)\n`);
|
|
35
|
+
if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
|
|
36
|
+
if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(`Page ${req.url}`));
|
|
37
|
+
return send(404, 'text/plain', 'no');
|
|
38
|
+
});
|
|
39
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
40
|
+
const port = server.address().port;
|
|
41
|
+
const apex = `http://lvh.me:${port}/`;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const a = await collect(apex, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
45
|
+
const check = (id) => a.checks.find((c) => c.id === id) || {};
|
|
46
|
+
ok('конечный адрес сайта на www', a.meta.site.startsWith(`http://www.lvh.me:${port}`));
|
|
47
|
+
ok(`robots.txt прочитан через 301 (${check('robots').status}: ${check('robots').value})`, check('robots').status === 'ok');
|
|
48
|
+
ok(`карта сайта найдена (${check('sitemap').value})`, check('sitemap').status === 'ok' && /6 URL/.test(check('sitemap').value));
|
|
49
|
+
ok('карта не считается чужим хостом', !a.checks.some((c) => c.id === 'sitemap-foreign'));
|
|
50
|
+
ok(`llms.txt прочитан через 301 (${check('llms').value})`, check('llms').status === 'ok');
|
|
51
|
+
ok('в обзоре шесть страниц в карте', a.overview.rows.find((r) => r[0] === 'Pages in sitemap')?.[1] === '6');
|
|
52
|
+
const sampled = Number((a.sources[0].detail.match(/homepage and (\d+) sampled/) || [])[1]);
|
|
53
|
+
ok(`выборка не одна главная (${sampled} страниц)`, sampled >= 5);
|
|
54
|
+
ok(`www-канонизация названа от голого домена к www (${check('www').value})`, /^lvh\.me:\d+ redirects to www\.lvh\.me/.test(check('www').value));
|
|
55
|
+
|
|
56
|
+
const v = await checkVisibility(apex, { lang: 'en', samplePages: 5, budgetMs: 8000 });
|
|
57
|
+
ok('бесплатная проверка прочитала сайт', v.ok);
|
|
58
|
+
ok(`бесплатная проверка нашла карту (${v.sitemap?.count})`, v.sitemap?.found && v.sitemap.count === 6);
|
|
59
|
+
ok(`в выборке не только главная (${v.sample?.length} страниц)`, (v.sample?.length || 0) >= 3);
|
|
60
|
+
} finally { server.close(); }
|
|
61
|
+
|
|
62
|
+
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
63
|
+
console.log('\nвсё сходится');
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Русский сайт в русском отчёте: правовые страницы находятся по русским адресам, и в audit.json
|
|
4
|
+
* нет английских подписей.
|
|
5
|
+
*
|
|
6
|
+
* 30.09.2026 образец аудита агентства из Екатеринбурга (collect --lang ru) дал красное «ни политики
|
|
7
|
+
* конфиденциальности, ни условий на сайте нет» при ссылке href="politika-konfidenczialnosti/", а в
|
|
8
|
+
* плане работ, обзоре, источниках и ограничениях стояли «Week 1», «Fix: Privacy and terms»,
|
|
9
|
+
* «The site itself» и «Analytics: no traffic or behaviour data».
|
|
10
|
+
*/
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { collect, policyKinds } from './collect.mjs';
|
|
16
|
+
import { draftNarrative } from './narrative.mjs';
|
|
17
|
+
import { render } from './render.mjs';
|
|
18
|
+
|
|
19
|
+
let bad = 0;
|
|
20
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
21
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
22
|
+
|
|
23
|
+
// ---- какие правовые страницы стоят за ссылкой
|
|
24
|
+
is('русская политика без косой черты в начале', policyKinds('politika-konfidenczialnosti/'), ['privacy']);
|
|
25
|
+
is('русская политика с косой чертой', policyKinds('/politika-konfidencialnosti/'), ['privacy']);
|
|
26
|
+
is('конфиденциальность отдельным словом', policyKinds('/konfidencialnost/'), ['privacy']);
|
|
27
|
+
is('согласие на обработку данных', policyKinds('/soglasie-na-obrabotku-personalnyh-dannyh/'), ['consent']);
|
|
28
|
+
is('пользовательское соглашение это условия, а не согласие', policyKinds('/polzovatelskoe-soglashenie/'), ['terms']);
|
|
29
|
+
is('условия и оферта', [policyKinds('usloviya-ispolzovaniya/'), policyKinds('/docs/oferta.pdf')], [['terms'], ['terms']]);
|
|
30
|
+
is('кириллица в адресе, в том числе закодированная', [policyKinds('/политика-конфиденциальности/'), policyKinds('/%D0%BE%D1%84%D0%B5%D1%80%D1%82%D0%B0/')], [['privacy'], ['terms']]);
|
|
31
|
+
is('полный адрес', policyKinds('https://dial93.ru/politika-konfidenczialnosti/'), ['privacy']);
|
|
32
|
+
is('английские адреса как раньше', [policyKinds('/privacy-policy'), policyKinds('/en/terms-of-use/'), policyKinds('/cookies'), policyKinds('/legal/'), policyKinds('/disclaimer')], [['privacy'], ['terms'], ['cookie'], ['legal'], ['disclaimer']]);
|
|
33
|
+
is('испанские акции не условия', policyKinds('/ofertas/'), []);
|
|
34
|
+
is('почта, телефон и якорь не страницы', [policyKinds('mailto:privacy@x.ru'), policyKinds('tel:+7'), policyKinds('#privacy')], [[], [], []]);
|
|
35
|
+
is('обычные страницы не правовые', [policyKinds('/kvartiry/'), policyKinds('/o-kompanii/'), policyKinds('/politics-news/')], [[], [], []]);
|
|
36
|
+
|
|
37
|
+
// ---- живой русский сайт, отчёт по-русски
|
|
38
|
+
const PAGES = ['/', '/kvartiry/', '/doma/', '/kontakty/'];
|
|
39
|
+
const page = (p) => `<!doctype html><html lang="ru"><head><title>Агентство недвижимости ${p}</title>
|
|
40
|
+
<meta name="description" content="Квартиры и дома в Екатеринбурге: подбор, проверка и сопровождение сделки под ключ.">
|
|
41
|
+
</head><body><main><h1>Недвижимость ${p}</h1>${'<p>Квартира в новом доме, 54 квадратных метра, цена 7,2 млн рублей, сдача в 2027 году.</p>'.repeat(12)}</main>
|
|
42
|
+
<footer><a href="politika-konfidenczialnosti/">Политика конфиденциальности</a> <a href="/soglasie-na-obrabotku/">Согласие на обработку</a></footer></body></html>`;
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
|
|
45
|
+
const www = `http://${req.headers.host}`;
|
|
46
|
+
if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
|
|
47
|
+
if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
|
|
48
|
+
if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(req.url));
|
|
49
|
+
return send(404, 'text/plain', 'нет');
|
|
50
|
+
});
|
|
51
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
52
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
53
|
+
|
|
54
|
+
// Английские подписи, которые сборщик писал сам до 0.33.1, и общий признак английской фразы.
|
|
55
|
+
const ENGLISH = /\b(Week|Weeks|Months|Critical, quick wins|Foundation|Organic growth|Fix:|CMS \/ stack|Pages in sitemap|Language|What is sold|The site itself|Free, no account|read exactly as|sampled page|Search Console: no|Analytics: no|Keyword and backlink|external tier|Entity clarity|Third-party mentions|Reviews and PR|Contact form|Price visible|Messengers|Paid tool)\b/;
|
|
56
|
+
const strings = (o, at = '', out = []) => {
|
|
57
|
+
if (typeof o === 'string') out.push([at, o]);
|
|
58
|
+
else if (Array.isArray(o)) o.forEach((x, i) => strings(x, `${at}[${i}]`, out));
|
|
59
|
+
else if (o && typeof o === 'object') for (const k of Object.keys(o)) strings(o[k], at ? `${at}.${k}` : k, out);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
// Что пишет сам сборщик. Проверки, выборка и сырые замеры несут адреса и значения со страниц.
|
|
63
|
+
const OWN = ['overview', 'critical', 'geo', 'conversion', 'sources', 'limitations', 'roadmap'];
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const a = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'ru', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
67
|
+
const pol = a.checks.find((c) => c.id === 'trust-policies') || {};
|
|
68
|
+
is('политика и согласие найдены, проверка зелёная', pol.status, 'ok');
|
|
69
|
+
ok(`и подписаны по-русски (${pol.value})`, /политика/.test(pol.value) && /согласие/.test(pol.value) && !/privacy|consent/.test(pol.value));
|
|
70
|
+
|
|
71
|
+
const leaks = strings(Object.fromEntries(OWN.map((k) => [k, a[k]]))).filter(([, s]) => ENGLISH.test(s) && !s.includes('{{'));
|
|
72
|
+
is('в русском audit.json нет английских подписей', leaks, []);
|
|
73
|
+
is('план работ', a.roadmap.map((p) => p.badge), ['Неделя 1', 'Недели 2-4', 'Месяцы 2-3']);
|
|
74
|
+
const badRu = a.checks.filter((c) => c.status === 'bad').map((c) => `Исправить: ${c.label}`);
|
|
75
|
+
ok(`пункты плана по русским названиям проверок (${a.roadmap[0].items[0]})`, badRu.length > 0 && JSON.stringify(a.roadmap[0].items.slice(0, -1)) === JSON.stringify(badRu));
|
|
76
|
+
ok('источник «Сам сайт» с числом страниц словами', a.sources[0].name === 'Сам сайт' && /и 4 страницы выборки/.test(a.sources[0].detail));
|
|
77
|
+
|
|
78
|
+
const d = draftNarrative(a, { lang: 'ru' });
|
|
79
|
+
const after = strings(Object.fromEntries(OWN.map((k) => [k, d[k]]))).filter(([, s]) => ENGLISH.test(s));
|
|
80
|
+
is('и после черновика тоже', after, []);
|
|
81
|
+
is('черновик нашёл русские строки обзора', d.overview.rows.map((r) => r[1].includes('{{')), [false, false, false, false]);
|
|
82
|
+
|
|
83
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'operstack-ru-'));
|
|
84
|
+
const out = path.join(dir, 'ru.html');
|
|
85
|
+
await render(d, { out });
|
|
86
|
+
const text = (await readFile(out, 'utf8')).replace(/<style[\s\S]*?<\/style>|<script[\s\S]*?<\/script>/gi, ' ').replace(/<[^>]+>/g, ' ');
|
|
87
|
+
const m = text.match(ENGLISH);
|
|
88
|
+
ok(`в напечатанном отчёте тоже${m ? `: «${text.slice(Math.max(0, m.index - 60), m.index + 60).replace(/\s+/g, ' ')}»` : ''}`, !m);
|
|
89
|
+
|
|
90
|
+
const en = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'en', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
91
|
+
is('английский отчёт не изменился', [en.roadmap[0].badge, en.sources[0].name, en.overview.rows[1][0], en.limitations.length], ['Week 1', 'The site itself', 'Pages in sitemap', 3]);
|
|
92
|
+
} finally { server.close(); }
|
|
93
|
+
|
|
94
|
+
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
95
|
+
console.log('\nвсё сходится');
|
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();
|