@operstack/audit 0.19.3 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/bin/audit.mjs +19 -0
- package/package.json +4 -3
- package/src/demand.mjs +506 -0
- package/src/index.mjs +2 -1
- package/src/prompts.mjs +81 -0
- package/src/render.mjs +16 -4
- package/src/test-demand.mjs +243 -0
- package/src/test-prompts.mjs +30 -1
package/README.md
CHANGED
|
@@ -130,6 +130,19 @@ Each sampled page is read for five things: does it open with an answer carrying
|
|
|
130
130
|
|
|
131
131
|
The letter states the scope and the shares; `--price` adds the money, because a short page is sometimes short on purpose and an automatic invoice for rewriting it is an invoice for work that should not happen. Utility pages are excluded and counted separately: nobody rewrites a privacy policy to open with a figure. Sources are asked for only where the page actually carries figures. Without Search Console access the scope is built on a sitemap sample, and the letter says so rather than implying we picked the pages that matter.
|
|
132
132
|
|
|
133
|
+
## The demand map: what people ask that the site has no page for
|
|
134
|
+
|
|
135
|
+
Half of "why no enquiries" is usually the same answer: people ask questions the site never wrote a page for. `demand` builds that map for the audit tier.
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
npx @operstack/audit demand audit.json --lang ru --region 225 --attach
|
|
139
|
+
npx @operstack/audit demand https://example.com --lang en --region 84 --dry
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Three sources, each named in the output. The queries come from free Yandex and Google autocomplete: seeds are the site's own H1 and page titles (brand words removed, phrases cut before the first number), eight patterns per seed, and only phrases that carry a topic word of the site survive, because autocomplete is happy to carry "property investment" off to Dubai. Volumes come from Topvisor, our paid tool (`TOPVISOR_USER_ID` and `TOPVISOR_KEY` in the environment; `--dry` prices the run and launches nothing; about 0.02 RUB per query). Whether the site has a page for a query is a word match against every URL in the sitemap plus the titles of up to `--titles` of them (short URLs first), with Cyrillic and transliterated slugs brought to one form.
|
|
143
|
+
|
|
144
|
+
The map is an appendix, page 11 of the report, and the sources table names Topvisor as paid and counted towards no score. The same text tells the buyer where to check the figures for free: Yandex Wordstat and the Google Ads Keyword Planner with their own account. A query without a volume says "no data", never 0. `--seeds "a; b"` overrides the automatic seeds when the analyst knows the niche better than the headings do; `--no-volumes` builds the map without numbers.
|
|
145
|
+
|
|
133
146
|
## Example
|
|
134
147
|
|
|
135
148
|
`examples/sample-audit.json` is a complete, fictional audit of "Example Villas". `npm run sample` renders it to HTML and PDF next to the JSON.
|
package/bin/audit.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist
|
|
|
30
30
|
import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
|
|
31
31
|
import { renderAgentPrompts, agentPrompts } from '../src/prompts.mjs';
|
|
32
32
|
import { render, checkNarrative } from '../src/render.mjs';
|
|
33
|
+
import { buildDemandMap, renderDemandMarkdown, demandCsv } from '../src/demand.mjs';
|
|
33
34
|
import { resolveBranding } from '../src/agency.mjs';
|
|
34
35
|
import { rank, readList, runBatch, runProspect, split, summarise, toCsv, toMarkdown } from '../src/batch.mjs';
|
|
35
36
|
import { writeFileSync, readFileSync } from 'node:fs';
|
|
@@ -51,6 +52,10 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
|
51
52
|
' fill the narrative from the checks: the analyst edits a draft instead of writing one',
|
|
52
53
|
' operstack-audit prompts <audit.json> [--lang ru|en] [--limit 3] [--out prompts.md]',
|
|
53
54
|
' every finding rewritten as a task you can paste into Cursor or Claude Code',
|
|
55
|
+
' operstack-audit demand <url | audit.json> [--lang ru|en] [--region 225] [--seeds "a; b"] [--max 200] [--titles 120] [--dry] [--no-volumes] [--attach]',
|
|
56
|
+
' what people in the niche ask and which of it the site has no page for; queries from free',
|
|
57
|
+
' autocomplete, volumes from Topvisor (TOPVISOR_USER_ID and TOPVISOR_KEY in env, --dry only prices it),',
|
|
58
|
+
' --attach writes the map into audit.json so render prints it as an appendix',
|
|
54
59
|
' operstack-audit check <audit.json>',
|
|
55
60
|
' list narrative fields still holding placeholders and any score the checks do not support',
|
|
56
61
|
'',
|
|
@@ -164,6 +169,20 @@ if (cmd === 'collect') {
|
|
|
164
169
|
writeFileSync(out, renderAgentPrompts(audit, { lang, limit }));
|
|
165
170
|
console.log(`${list.length} task(s): ${list.filter((x) => x.hasOwnText).length} written for that finding, ${list.filter((x) => !x.hasOwnText).length} generic`);
|
|
166
171
|
console.log(`wrote ${out}`);
|
|
172
|
+
} else if (cmd === 'demand') {
|
|
173
|
+
const isAudit = /\.json$/i.test(target || '');
|
|
174
|
+
const audit = isAudit ? JSON.parse(readFileSync(resolve(target), 'utf8')) : null;
|
|
175
|
+
const url = isAudit ? audit.meta.site : target;
|
|
176
|
+
const lang = opt('--lang', audit?.meta?.lang || 'en') === 'ru' ? 'ru' : 'en';
|
|
177
|
+
const seeds = opt('--seeds', '') ? opt('--seeds').split(';').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
178
|
+
const d = await buildDemandMap(url, { lang, region: opt('--region'), seeds, pages: audit?.sample, brand: audit?.client?.name || '', max: Number(opt('--max', 200)) || 200, titles: Number(opt('--titles', 120)), dry: has('--dry'), volumes: !has('--no-volumes'), log: console.log });
|
|
179
|
+
const base = resolve(opt('--out', (isAudit ? target.replace(/\.json$/i, '') : d.host) + '-demand'));
|
|
180
|
+
writeFileSync(`${base}.json`, JSON.stringify(d, null, 2));
|
|
181
|
+
writeFileSync(`${base}.md`, renderDemandMarkdown(d));
|
|
182
|
+
writeFileSync(`${base}.csv`, demandCsv(d));
|
|
183
|
+
if (audit && has('--attach')) { audit.demand = d; writeFileSync(resolve(target), JSON.stringify(audit, null, 2)); console.log(`attached to ${target}`); }
|
|
184
|
+
console.log(`${d.totals.queries} queries, ${d.totals.uncovered} without a page; volumes: ${d.volumes.source}${d.volumes.price !== null && d.volumes.price !== undefined ? `, ${d.volumes.price} RUB` : ''}${d.volumes.note ? ` (${d.volumes.note})` : ''}`);
|
|
185
|
+
console.log(`wrote ${base}.json, .md, .csv`);
|
|
167
186
|
} else if (cmd === 'fix-plan') {
|
|
168
187
|
const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
|
|
169
188
|
// Язык покупки решает и валюту, и письмо: за доллары платят по английскому списку, за рубли по русскому.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"./foundation": "./src/foundation.mjs",
|
|
17
17
|
"./narrative": "./src/narrative.mjs",
|
|
18
18
|
"./prompts": "./src/prompts.mjs",
|
|
19
|
-
"./rendered": "./src/rendered.mjs"
|
|
19
|
+
"./rendered": "./src/rendered.mjs",
|
|
20
|
+
"./demand": "./src/demand.mjs"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"bin",
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
30
31
|
"scripts": {
|
|
31
|
-
"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-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",
|
|
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-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",
|
|
32
33
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
33
34
|
"verify:live": "node src/verify-live.mjs"
|
|
34
35
|
},
|
package/src/demand.mjs
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Карта спроса: что люди в нише спрашивают на самом деле и под что у сайта нет страницы.
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Аудит отвечает на вопрос «почему не идут заявки», и половина ответа почти
|
|
5
|
+
* всегда одна: люди спрашивают не то, о чём написан сайт. Эта карта показывает три вещи.
|
|
6
|
+
* Какие запросы задают в нише: их даёт бесплатное автодополнение Яндекса и Google, то есть
|
|
7
|
+
* это фразы, которые люди уже набирали. Сколько раз в месяц их задают: частоты из Topvisor,
|
|
8
|
+
* нашего платного инструмента. И есть ли на сайте страница под каждый запрос: сопоставление
|
|
9
|
+
* по словам адреса и заголовка, оценка, а не чтение смысла, и так и подписано.
|
|
10
|
+
*
|
|
11
|
+
* Правило от 11 сентября: платные данные не входят ни в один балл. Карта спроса это пометка
|
|
12
|
+
* к отчёту, а не оценка, и рядом всегда написано, где покупатель проверит те же числа сам и
|
|
13
|
+
* бесплатно: Wordstat Яндекса и Планировщик ключевых слов Google, со своим аккаунтом.
|
|
14
|
+
*
|
|
15
|
+
* Ни одного числа, которого нет в источнике: запрос без частоты так и подписывается словами.
|
|
16
|
+
* Ни одного лишнего рубля: цена спрашивается до запуска и пишется в результат.
|
|
17
|
+
*/
|
|
18
|
+
import { parse } from 'node-html-parser';
|
|
19
|
+
|
|
20
|
+
const UA = 'Mozilla/5.0 (compatible; OperStack demand map; +https://oper-stack.com)';
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
23
|
+
|
|
24
|
+
export const DEMAND_DEFAULTS = { max: 200, delayMs: 150, pollMs: 15000, timeoutMs: 20 * 60000, seedsMax: 12, sitemapMax: 3000 };
|
|
25
|
+
export const DEMAND_PROJECT = 'OperStack: карта спроса';
|
|
26
|
+
|
|
27
|
+
/** Регионы, с которыми мы работаем: ключ Topvisor (он же id геобазы Яндекса) и страна для подсказок Google. */
|
|
28
|
+
export const REGIONS = {
|
|
29
|
+
225: { ru: 'Россия', en: 'Russia', gl: 'ru', engine: 'yandex' },
|
|
30
|
+
213: { ru: 'Москва', en: 'Moscow', gl: 'ru', engine: 'yandex' },
|
|
31
|
+
2: { ru: 'Санкт-Петербург', en: 'Saint Petersburg', gl: 'ru', engine: 'yandex' },
|
|
32
|
+
84: { ru: 'США', en: 'United States', gl: 'us', engine: 'google' },
|
|
33
|
+
102: { ru: 'Великобритания', en: 'United Kingdom', gl: 'gb', engine: 'google' },
|
|
34
|
+
95: { ru: 'Канада', en: 'Canada', gl: 'ca', engine: 'google' },
|
|
35
|
+
211: { ru: 'Австралия', en: 'Australia', gl: 'au', engine: 'google' },
|
|
36
|
+
96: { ru: 'Германия', en: 'Germany', gl: 'de', engine: 'google' },
|
|
37
|
+
210: { ru: 'ОАЭ', en: 'United Arab Emirates', gl: 'ae', engine: 'google' },
|
|
38
|
+
246: { ru: 'Греция', en: 'Greece', gl: 'gr', engine: 'google' },
|
|
39
|
+
983: { ru: 'Турция', en: 'Turkey', gl: 'tr', engine: 'google' },
|
|
40
|
+
994: { ru: 'Индия', en: 'India', gl: 'in', engine: 'google' },
|
|
41
|
+
995: { ru: 'Таиланд', en: 'Thailand', gl: 'th', engine: 'google' },
|
|
42
|
+
10620: { ru: 'Бангкок', en: 'Bangkok', gl: 'th', engine: 'google' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Шаблоны, которыми зерно превращается в вопросы. Порядок важен: сначала само зерно. */
|
|
46
|
+
export const PATTERNS = {
|
|
47
|
+
ru: ['{q}', 'как {q}', 'сколько стоит {q}', 'где {q}', '{q} цена', '{q} отзывы', 'что такое {q}', '{q} или'],
|
|
48
|
+
en: ['{q}', 'how to {q}', '{q} cost', 'best {q}', 'what is {q}', '{q} price', '{q} near me', '{q} vs'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Служебные слова не участвуют в сопоставлении: «как купить квартиру в сочи» и страница
|
|
52
|
+
// «купить квартиру в Сочи» это одна тема, слово «как» темы не меняет.
|
|
53
|
+
const STOP = {
|
|
54
|
+
ru: new Set('и в во на с со к ко по о об от до из за для при не что как это или а но у же ли бы то еще сколько стоит где цена отзывы такое лучшие лучший какой какая какие чем про без купить продажа продать заказать стоимость'.split(' ')),
|
|
55
|
+
en: new Set('the a an and or of to in on for with at by from is are be it this that vs near me how what best cost price which who why when where do does can should my your buy purchase sale'.split(' ')),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Адреса русских сайтов почти всегда в латинице (/kvartiry-v-sochi/), а запросы в кириллице.
|
|
59
|
+
// Чтобы «квартиры в сочи» нашли свою страницу, слова приводятся к одной латинской основе:
|
|
60
|
+
// транслитерация, схлопывание вариантов написания (kh и h, ts и c, y и i), первые пять букв.
|
|
61
|
+
const TRANSLIT = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'c', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' };
|
|
62
|
+
const canon = (w) => {
|
|
63
|
+
const latin = [...w].map((ch) => (TRANSLIT[ch] !== undefined ? TRANSLIT[ch] : ch)).join('');
|
|
64
|
+
const same = latin.replace(/shch/g, 'sch').replace(/kh/g, 'h').replace(/ts/g, 'c').replace(/[yj]/g, 'i').replace(/'/g, '');
|
|
65
|
+
return same.length > 5 ? same.slice(0, 5) : same;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Слова, по которым запрос сравнивается со страницей: без служебных, приведённые к одной основе. */
|
|
69
|
+
export function tokens(s, lang = 'en') {
|
|
70
|
+
return [...new Set(String(s || '').toLowerCase().replace(/ё/g, 'е').replace(/[^a-zа-я0-9\s-]/g, ' ').split(/[\s-]+/)
|
|
71
|
+
.filter((w) => w.length >= 3 && !STOP.ru.has(w) && !STOP.en.has(w))
|
|
72
|
+
.map(canon))];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SPLIT_TITLE = /\s[|·•–—-]\s|:|\||\.\s|\?|!/;
|
|
76
|
+
const GENERIC = /^(главная|главная страница|home|home page|homepage|контакты|contact us|contacts|about us|о нас|о компании|blog|блог|news|новости|privacy policy|политика конфиденциальности)$/;
|
|
77
|
+
// Призывы, мессенджеры и служебные пункты меню: это не темы ниши, а кнопки.
|
|
78
|
+
const JUNK = /получит|подобрать|подборк|заказать|заказ|скачать|записаться|оставить|заявк|позвонить|написать|консультац|whatsapp|telegram|viber|vpn|\bmax\b|войти|вход|регистрац|login|sign in|sign up|contact|download|get a |book a |request|subscribe|подписаться|cookie|политика/i;
|
|
79
|
+
const TAIL_STOP = new Set('в во на с со к ко по о об от до из за для при и или а но у the a an and or of to in on for with at by from'.split(' '));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Зёрна: о чём сайт, по его же заголовкам. Слова бренда вырезаются, иначе подсказки
|
|
83
|
+
* будут про название компании, а не про нишу. Фраза режется перед первым числом («новые
|
|
84
|
+
* проекты на Пхукете от 4,6 млн» становится «новые проекты на Пхукете»), потому что цена
|
|
85
|
+
* и срок это не тема, а хвостовой предлог после обрезки отбрасывается.
|
|
86
|
+
*/
|
|
87
|
+
function brandWordsOf(pages, host, brand) {
|
|
88
|
+
const brandWords = new Set([
|
|
89
|
+
...String(host || '').replace(/^www\./, '').split('.')[0].toLowerCase().split('-').filter((w) => w.length >= 3),
|
|
90
|
+
...clean(brand).toLowerCase().split(/[\s|:]+/).filter((w) => w.length >= 2),
|
|
91
|
+
]);
|
|
92
|
+
// Сегмент заголовка, который повторяется на нескольких страницах, это тоже бренд.
|
|
93
|
+
const segCount = new Map();
|
|
94
|
+
for (const p of pages || []) for (const seg of clean(p.title).toLowerCase().split(SPLIT_TITLE)) { const k = clean(seg); if (k) segCount.set(k, (segCount.get(k) || 0) + 1); }
|
|
95
|
+
for (const [seg, n] of segCount) if (n >= 2 && (pages || []).length >= 2) for (const w of seg.split(' ')) brandWords.add(w);
|
|
96
|
+
return brandWords;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Якоря: слова темы сайта из H1 и заголовков страниц, без бренда. Запрос без якоря в карту
|
|
101
|
+
* не попадает: автодополнение охотно уводит «инвестиции в недвижимость» в Дубай, а сайт про
|
|
102
|
+
* Камбоджу. Лучше меньше запросов, но все про этот сайт.
|
|
103
|
+
*/
|
|
104
|
+
export function anchorsFromPages(pages, { host = '', brand = '', lang = 'en' } = {}) {
|
|
105
|
+
const brandWords = brandWordsOf(pages, host, brand);
|
|
106
|
+
const brandTok = new Set([...brandWords].flatMap((w) => tokens(w, lang)));
|
|
107
|
+
const out = new Set();
|
|
108
|
+
for (const p of pages || []) for (const raw of [p.h1, p.title]) for (const words of segments(raw, brandWords)) {
|
|
109
|
+
for (const t of tokens(words.join(' '), lang)) if (!brandTok.has(t) && !UNITS.has(t)) out.add(t);
|
|
110
|
+
}
|
|
111
|
+
// Слова из имени домена это чаще ниша, чем бренд: invest-cambodia, florida-estate, greek-invest.
|
|
112
|
+
// Заголовок такого сайта может быть лозунгом без географии, и без этих слов якорей не остаётся.
|
|
113
|
+
// Склеенное имя без дефисов (moregroupestate, globalyachtguide) словом не является.
|
|
114
|
+
for (const w of String(host || '').replace(/^www\./, '').split('.')[0].toLowerCase().split('-')) {
|
|
115
|
+
if (w.length >= 4 && w.length <= 10 && !HOST_GENERIC.has(w)) for (const t of tokens(w, lang)) out.add(t);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
const HOST_GENERIC = new Set(['www', 'shop', 'online', 'site', 'web', 'group', 'best', 'top', 'official', 'company', 'estate', 'invest', 'agency', 'media', 'blog', 'news']);
|
|
120
|
+
|
|
121
|
+
// Единицы и сроки из заголовков («от 4,6 млн ₽», «за 2 часа») темы не задают.
|
|
122
|
+
const UNITS = new Set(['mln', 'tys', 'rub', 'usd', 'eur', 'chas', 'chasa', 'chaso', 'minut', 'dnei', 'dnia', 'nedel', 'mesia', 'goda', 'let', 'hour', 'hours', 'days', 'weeks', 'month', 'years', 'min', 'max']);
|
|
123
|
+
|
|
124
|
+
/** Сегменты заголовка словами: разделители, обрезка перед первым числом, хвостовой предлог и бренд долой. */
|
|
125
|
+
function segments(raw, brandWords) {
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const part of clean(raw).split(SPLIT_TITLE)) {
|
|
128
|
+
let words = clean(part).toLowerCase().replace(/[«»"()]/g, '').split(' ').filter(Boolean);
|
|
129
|
+
const firstDigit = words.findIndex((w) => /\d/.test(w));
|
|
130
|
+
if (firstDigit === 0) continue;
|
|
131
|
+
if (firstDigit > 0) words = words.slice(0, firstDigit);
|
|
132
|
+
while (words.length && TAIL_STOP.has(words[words.length - 1])) words.pop();
|
|
133
|
+
words = words.filter((w) => !brandWords.has(w) && /[a-zа-яё]/i.test(w));
|
|
134
|
+
if (words.length) out.push(words);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function seedsFromPages(pages, { host = '', brand = '', lang = 'en', max = DEMAND_DEFAULTS.seedsMax } = {}) {
|
|
140
|
+
const brandWords = brandWordsOf(pages, host, brand);
|
|
141
|
+
const anchors = anchorsFromPages(pages, { host, brand, lang });
|
|
142
|
+
const seen = new Set(); const out = [];
|
|
143
|
+
const consider = (raw, anchored) => {
|
|
144
|
+
for (const words of segments(raw, brandWords)) {
|
|
145
|
+
const s = words.join(' ');
|
|
146
|
+
if (words.length < 2 || words.length > 7 || GENERIC.test(s) || JUNK.test(s) || seen.has(s)) continue;
|
|
147
|
+
const tok = tokens(s, lang);
|
|
148
|
+
if (tok.length < 2) continue;
|
|
149
|
+
// Подзаголовки и меню годятся в зёрна, только если держатся за тему: «гибкие планы
|
|
150
|
+
// оплаты» и «что изменилось» это лозунги, а не то, что люди ищут.
|
|
151
|
+
if (anchored && !tok.some((t) => anchors.has(t))) continue;
|
|
152
|
+
seen.add(s); out.push(s);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
// Сначала H1 и заголовки страниц: это темы. Меню в конце: там чаще кнопки, чем темы.
|
|
156
|
+
for (const p of pages || []) consider(p.h1, false);
|
|
157
|
+
for (const p of pages || []) consider(p.title, false);
|
|
158
|
+
for (const p of pages || []) for (const h of p.h2 || []) consider(h, true);
|
|
159
|
+
for (const p of pages || []) for (const n of p.nav || []) consider(n, true);
|
|
160
|
+
return out.slice(0, max);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function defaultFetchText(url) {
|
|
164
|
+
const res = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xml,text/plain,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(20000) });
|
|
165
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
166
|
+
return res.text();
|
|
167
|
+
}
|
|
168
|
+
async function defaultFetchJson(url) {
|
|
169
|
+
const res = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(8000) });
|
|
170
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
171
|
+
return JSON.parse(await res.text());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Главная как страница для зёрен: заголовок, H1, подзаголовки H2, пункты меню и бренд из og:site_name. */
|
|
175
|
+
export async function homePage(url, { fetchText = defaultFetchText } = {}) {
|
|
176
|
+
const root = parse(await fetchText(url));
|
|
177
|
+
const text = (el) => clean(el ? el.text : '');
|
|
178
|
+
return {
|
|
179
|
+
url, title: text(root.querySelector('title')), h1: text(root.querySelector('h1')),
|
|
180
|
+
h2: root.querySelectorAll('h2').map(text).slice(0, 12),
|
|
181
|
+
nav: root.querySelectorAll('nav a, header a').map(text).filter((t) => t.split(' ').length >= 2).slice(0, 30),
|
|
182
|
+
brand: root.querySelector('meta[property="og:site_name"]')?.getAttribute('content') || '',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Зёрна с самой главной. */
|
|
187
|
+
export async function seedsFromSite(url, { fetchText = defaultFetchText, host, max, lang } = {}) {
|
|
188
|
+
const page = await homePage(url, { fetchText });
|
|
189
|
+
return seedsFromPages([page], { host: host || new URL(url).host, brand: page.brand, max, lang });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Одна подсказка: что автодополнение предлагает на эту фразу. Оба ответа имеют вид [фраза, [варианты]]. */
|
|
193
|
+
export async function suggest(q, { engine = 'google', lang = 'en', gl = 'us', lr = 225, fetchJson = defaultFetchJson } = {}) {
|
|
194
|
+
const url = engine === 'yandex'
|
|
195
|
+
? `https://suggest.yandex.ru/suggest-ff.cgi?part=${encodeURIComponent(q)}&lr=${lr}`
|
|
196
|
+
: `https://suggestqueries.google.com/complete/search?client=firefox&hl=${lang}&gl=${gl}&q=${encodeURIComponent(q)}`;
|
|
197
|
+
const body = await fetchJson(url);
|
|
198
|
+
const list = Array.isArray(body) && Array.isArray(body[1]) ? body[1] : [];
|
|
199
|
+
return list.map((x) => (Array.isArray(x) ? x[0] : x)).map(clean).filter(Boolean);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Из зёрен в запросы. Каждое зерно прогоняется через шаблоны и подсказки; остаются фразы
|
|
204
|
+
* из двух и более слов, у которых есть общее слово с зерном (иначе автодополнение уводит
|
|
205
|
+
* в сторону). Квота делится поровну между зёрнами: ниша шире одного заголовка.
|
|
206
|
+
*/
|
|
207
|
+
export async function expandQueries(seeds, { lang = 'en', region = 84, max = DEMAND_DEFAULTS.max, delayMs = DEMAND_DEFAULTS.delayMs, fetchJson, engines, anchors = null } = {}) {
|
|
208
|
+
const ru = lang === 'ru';
|
|
209
|
+
const gl = REGIONS[region]?.gl || (ru ? 'ru' : 'us');
|
|
210
|
+
const use = engines || (ru ? ['yandex', 'google'] : ['google']);
|
|
211
|
+
const perSeed = new Map(); let calls = 0; let failed = 0;
|
|
212
|
+
for (const seed of seeds) {
|
|
213
|
+
const bag = new Set();
|
|
214
|
+
const seedTok = tokens(seed, lang);
|
|
215
|
+
const anchorSet = anchors && anchors.size !== undefined ? anchors : (anchors ? new Set(anchors) : null);
|
|
216
|
+
const add = (s) => {
|
|
217
|
+
const n = clean(s).toLowerCase();
|
|
218
|
+
if (!n || bag.has(n) || n.split(' ').length < 2) return;
|
|
219
|
+
const tok = tokens(n, lang);
|
|
220
|
+
if (!tok.some((t) => seedTok.includes(t))) return;
|
|
221
|
+
if (anchorSet && anchorSet.size && !tok.some((t) => anchorSet.has(t))) return;
|
|
222
|
+
bag.add(n);
|
|
223
|
+
};
|
|
224
|
+
add(seed);
|
|
225
|
+
for (const tpl of PATTERNS[ru ? 'ru' : 'en']) {
|
|
226
|
+
const q = tpl.replace('{q}', seed);
|
|
227
|
+
for (const engine of use) {
|
|
228
|
+
calls += 1;
|
|
229
|
+
try { for (const s of await suggest(q, { engine, lang: ru ? 'ru' : 'en', gl, lr: region, fetchJson })) add(s); } catch { failed += 1; }
|
|
230
|
+
if (delayMs) await sleep(delayMs);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
perSeed.set(seed, [...bag]);
|
|
234
|
+
}
|
|
235
|
+
const out = []; const seen = new Set();
|
|
236
|
+
for (let i = 0, more = true; more && out.length < max; i++) {
|
|
237
|
+
more = false;
|
|
238
|
+
for (const list of perSeed.values()) {
|
|
239
|
+
if (i >= list.length) continue;
|
|
240
|
+
more = true;
|
|
241
|
+
if (!seen.has(list[i])) { seen.add(list[i]); out.push(list[i]); }
|
|
242
|
+
if (out.length >= max) break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { queries: out, calls, failed, engines: use, perSeed: Object.fromEntries([...perSeed].map(([k, v]) => [k, v.length])) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Клиент Topvisor: JSON-вызов и импорт ключей формой. Без ключей возвращает null. */
|
|
249
|
+
export function topvisorClient({ userId = process.env.TOPVISOR_USER_ID, key = process.env.TOPVISOR_KEY, base = 'https://api.topvisor.com/v2/json/', fetchFn = globalThis.fetch } = {}) {
|
|
250
|
+
if (!userId || !key) return null;
|
|
251
|
+
const headers = { 'User-Id': String(userId).trim(), Authorization: `bearer ${String(key).trim()}` };
|
|
252
|
+
const unwrap = async (res, method) => {
|
|
253
|
+
const body = await res.json().catch(() => ({}));
|
|
254
|
+
if (body.errors && body.errors.length) throw new Error(`Topvisor ${method}: ${body.errors.map((e) => e.string || e.code).join('; ')}`);
|
|
255
|
+
return body.result;
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
async call(method, body = {}) {
|
|
259
|
+
const res = await fetchFn(base + method, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(60000) });
|
|
260
|
+
return unwrap(res, method);
|
|
261
|
+
},
|
|
262
|
+
async importKeywords(projectId, groupId, keywords) {
|
|
263
|
+
const boundary = `----operstack${Date.now()}`;
|
|
264
|
+
const part = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
265
|
+
const body = part('project_id', projectId) + part('group_id', groupId) + part('keywords', keywords.join('\n')) + `--${boundary}--\r\n`;
|
|
266
|
+
const res = await fetchFn(`${base}add/keywords_2/keywords/import`, { method: 'POST', headers: { ...headers, 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body, signal: AbortSignal.timeout(120000) });
|
|
267
|
+
return unwrap(res, 'add/keywords_2/keywords/import');
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Частоты через Topvisor. Проект один на все карты, группа на каждый прогон. Порядок:
|
|
274
|
+
* проект, поисковик и регион в нём, группа, импорт, цена, запуск, опрос до заполнения.
|
|
275
|
+
* `dry` останавливается после цены: видно, сколько это стоит, и ничего не списано.
|
|
276
|
+
*/
|
|
277
|
+
export async function volumesViaTopvisor(queries, { lang = 'en', region = 84, engine, type, client, projectName = DEMAND_PROJECT, groupName, dry = false, pollMs = DEMAND_DEFAULTS.pollMs, timeoutMs = DEMAND_DEFAULTS.timeoutMs, log = () => {}, now = Date.now } = {}) {
|
|
278
|
+
const eng = engine || REGIONS[region]?.engine || (lang === 'ru' ? 'yandex' : 'google');
|
|
279
|
+
const searcher = eng === 'yandex' ? 0 : 1;
|
|
280
|
+
const vtype = type || (eng === 'yandex' ? 1 : 3);
|
|
281
|
+
const field = `volume:${region}:${searcher}:${vtype}`;
|
|
282
|
+
const result = { source: 'Topvisor', engine: eng, region: Number(region), type: vtype, price: null, currency: 'RUB', asked: queries.length, filled: 0, volumes: {}, projectId: null, groupId: null, launched: false, note: '' };
|
|
283
|
+
if (!client) { result.source = 'none'; result.note = 'no credentials'; return result; }
|
|
284
|
+
if (!queries.length) { result.note = 'no queries'; return result; }
|
|
285
|
+
|
|
286
|
+
const list = async () => (await client.call('get/projects_2/projects', { fields: ['id', 'name'], limit: 1000 })) || [];
|
|
287
|
+
let project = (await list()).find((p) => p.name === projectName);
|
|
288
|
+
if (!project) { await client.call('add/projects_2/projects', { url: 'oper-stack.com', name: projectName }); project = (await list()).find((p) => p.name === projectName); }
|
|
289
|
+
if (!project) throw new Error('Topvisor: проект для карты спроса не создался');
|
|
290
|
+
result.projectId = project.id;
|
|
291
|
+
|
|
292
|
+
// Поисковик и регион в проекте: без них частоты не запускаются. «Уже есть» не ошибка.
|
|
293
|
+
const soft = async (method, body) => { try { await client.call(method, body); } catch (e) { if (!/уже|already|exist|существ/i.test(e.message)) log(` ${e.message}`); } };
|
|
294
|
+
await soft('add/positions_2/searchers', { project_id: project.id, searcher_key: searcher });
|
|
295
|
+
await soft('add/positions_2/searchers/regions', { project_id: project.id, searcher_key: searcher, region_key: Number(region), region_lang: lang === 'ru' ? 'ru' : 'en', region_device: 0, region_depth: 1 });
|
|
296
|
+
const [prj] = (await client.call('get/projects_2/projects', { fields: ['id'], show_searchers_and_regions: 1, filters: [{ name: 'id', operator: 'EQUALS', values: [project.id] }] })) || [];
|
|
297
|
+
const haveRegion = (prj?.searchers || []).some((s) => Number(s.key) === searcher && (s.regions || []).some((r) => Number(r.key) === Number(region)));
|
|
298
|
+
if (!haveRegion) throw new Error(`Topvisor: регион ${region} для поисковика ${eng} не добавился в проект`);
|
|
299
|
+
|
|
300
|
+
const gname = groupName || `${new Date().toISOString().slice(0, 10)} ${region}`;
|
|
301
|
+
await client.call('add/keywords_2/groups', { project_id: project.id, name: [gname] }).catch(() => client.call('add/keywords_2/groups', { project_id: project.id, names: [gname] }));
|
|
302
|
+
const groups = (await client.call('get/keywords_2/groups', { project_id: project.id, fields: ['id', 'name'], limit: 1000 })) || [];
|
|
303
|
+
const group = [...groups].reverse().find((g) => g.name === gname);
|
|
304
|
+
if (!group) throw new Error('Topvisor: группа не создалась');
|
|
305
|
+
result.groupId = group.id;
|
|
306
|
+
await client.importKeywords(project.id, group.id, queries);
|
|
307
|
+
|
|
308
|
+
const filters = [{ name: 'group_id', operator: 'EQUALS', values: [group.id] }];
|
|
309
|
+
const q = { project_id: project.id, qualifiers: [{ region_key: Number(region), searcher_key: searcher, type: vtype }], target_type: 'keywords', filters };
|
|
310
|
+
const priceRes = await client.call('get/keywords_2/volumes/price', q);
|
|
311
|
+
const p = priceRes && priceRes.pricesByUsers ? Object.values(priceRes.pricesByUsers)[0] : null;
|
|
312
|
+
result.price = p && Number.isFinite(Number(p.price)) ? Number(p.price) : null;
|
|
313
|
+
log(` Topvisor: ${queries.length} запросов, ${eng}, регион ${region}, цена ${result.price === null ? 'неизвестна' : `${result.price} ₽`}`);
|
|
314
|
+
if (dry) { result.note = 'dry run: not launched'; return result; }
|
|
315
|
+
|
|
316
|
+
await client.call('edit/keywords_2/volumes/go', q);
|
|
317
|
+
result.launched = true;
|
|
318
|
+
const started = now();
|
|
319
|
+
for (;;) {
|
|
320
|
+
const rows = (await client.call('get/keywords_2/keywords', { project_id: project.id, fields: ['name', field], filters, limit: 10000 })) || [];
|
|
321
|
+
const vols = {}; let filled = 0;
|
|
322
|
+
for (const r of rows) { const v = r[field]; if (v !== null && v !== undefined && v !== '') { vols[clean(r.name).toLowerCase()] = Number(v); filled += 1; } }
|
|
323
|
+
result.volumes = vols; result.filled = filled;
|
|
324
|
+
if (rows.length && filled >= rows.length) break;
|
|
325
|
+
if (now() - started > timeoutMs) { result.note = `timeout: ${filled} of ${rows.length}`; break; }
|
|
326
|
+
await sleep(pollMs);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Все адреса из карт сайта, чтобы было с чем сопоставлять запросы. Только адреса: заголовков у нас нет. */
|
|
332
|
+
export async function sitemapPages(url, { fetchText = defaultFetchText, max = DEMAND_DEFAULTS.sitemapMax } = {}) {
|
|
333
|
+
const origin = new URL(url).origin;
|
|
334
|
+
const robots = await fetchText(`${origin}/robots.txt`).catch(() => '');
|
|
335
|
+
const listed = [...robots.matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]);
|
|
336
|
+
const queue = listed.length ? listed : [`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`];
|
|
337
|
+
const urls = new Set(); const seen = new Set(); let files = 0;
|
|
338
|
+
while (queue.length && urls.size < max && files < 30) {
|
|
339
|
+
const u = queue.shift();
|
|
340
|
+
if (seen.has(u)) continue;
|
|
341
|
+
seen.add(u); files += 1;
|
|
342
|
+
const xml = await fetchText(u).catch(() => '');
|
|
343
|
+
if (!xml) continue;
|
|
344
|
+
const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1]);
|
|
345
|
+
if (/<sitemapindex/i.test(xml)) queue.push(...locs);
|
|
346
|
+
else for (const l of locs) { if (urls.size >= max) break; urls.add(l); }
|
|
347
|
+
}
|
|
348
|
+
return [...urls].map((u) => ({ url: u, title: '', h1: '' }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Заголовки для адресов из карты сайта. Адреса русских сайтов часто английские (/districts/),
|
|
353
|
+
* а запросы русские, и по одному адресу «районы пхукета» свою страницу не найдут. Заголовок
|
|
354
|
+
* страницы написан на языке сайта, поэтому дочитываем его для первых `max` адресов, начиная
|
|
355
|
+
* с коротких: разделы важнее глубоких карточек. Страница не ответила, значит остаётся адрес.
|
|
356
|
+
*/
|
|
357
|
+
export async function enrichTitles(pages, { fetchText = defaultFetchText, max = 120, concurrency = 4 } = {}) {
|
|
358
|
+
const depth = (u) => { try { return new URL(u).pathname.split('/').filter(Boolean).length; } catch { return 99; } };
|
|
359
|
+
const todo = (pages || []).filter((p) => p && p.url && !p.title && !p.h1).sort((a, b) => depth(a.url) - depth(b.url) || a.url.length - b.url.length).slice(0, Math.max(0, max));
|
|
360
|
+
let i = 0; let done = 0;
|
|
361
|
+
const worker = async () => {
|
|
362
|
+
while (i < todo.length) {
|
|
363
|
+
const p = todo[i++];
|
|
364
|
+
try {
|
|
365
|
+
const root = parse(await fetchText(p.url));
|
|
366
|
+
p.title = clean(root.querySelector('title')?.text); p.h1 = clean(root.querySelector('h1')?.text);
|
|
367
|
+
if (p.title || p.h1) done += 1;
|
|
368
|
+
} catch { /* остаётся адрес */ }
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
|
|
372
|
+
return done;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const pageTokens = (p, lang) => {
|
|
376
|
+
let path = '';
|
|
377
|
+
try { path = decodeURIComponent(new URL(p.url).pathname); } catch { path = String(p.url || ''); }
|
|
378
|
+
return new Set(tokens(`${path.replace(/[/_.]+/g, ' ')} ${p.title || ''} ${p.h1 || ''}`, lang));
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/** Есть ли под запрос страница: все значимые слова запроса встречаются в адресе или заголовке одной страницы. */
|
|
382
|
+
export function coverage(queries, pages, { lang = 'en' } = {}) {
|
|
383
|
+
const index = (pages || []).filter((p) => p && p.url).map((p) => ({ url: p.url, tok: pageTokens(p, lang) }));
|
|
384
|
+
return queries.map((q) => {
|
|
385
|
+
const qt = tokens(q, lang);
|
|
386
|
+
if (!qt.length) return { q, covered: false, url: null };
|
|
387
|
+
const hit = index.find((p) => qt.every((t) => p.tok.has(t)));
|
|
388
|
+
return { q, covered: Boolean(hit), url: hit ? hit.url : null };
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function demandTotals(queries) {
|
|
393
|
+
const known = queries.filter((q) => q.volume !== null && q.volume !== undefined);
|
|
394
|
+
const sum = (l) => l.reduce((a, q) => a + (Number(q.volume) || 0), 0);
|
|
395
|
+
return {
|
|
396
|
+
queries: queries.length, withVolume: known.length,
|
|
397
|
+
covered: queries.filter((q) => q.covered).length, uncovered: queries.filter((q) => !q.covered).length,
|
|
398
|
+
volumeTotal: sum(known), volumeCovered: sum(known.filter((q) => q.covered)), volumeUncovered: sum(known.filter((q) => !q.covered)),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Вся карта одним вызовом. Страницы можно передать из audit.json (`sample`), тогда зёрна
|
|
404
|
+
* берутся из их заголовков; иначе читается главная. Адреса для сопоставления всегда
|
|
405
|
+
* дочитываются из карт сайта.
|
|
406
|
+
*/
|
|
407
|
+
export async function buildDemandMap(url, opts = {}) {
|
|
408
|
+
const { lang = 'en', region: regionIn, seeds: seedsIn, pages: pagesIn, max, dry = false, volumes = true, client = topvisorClient(), fetchText = defaultFetchText, fetchJson = defaultFetchJson, log = () => {}, engines, delayMs, pollMs, timeoutMs, groupName, now, titles = 120, brand = '' } = opts;
|
|
409
|
+
const region = Number(regionIn) || (lang === 'ru' ? 225 : 84);
|
|
410
|
+
const host = new URL(url).host.replace(/^www\./, '');
|
|
411
|
+
log(`карта спроса: ${host}, язык ${lang}, регион ${region} (${REGIONS[region]?.[lang] || region})`);
|
|
412
|
+
const pages = pagesIn && pagesIn.length ? pagesIn : [await homePage(url, { fetchText })];
|
|
413
|
+
const brandName = brand || pages.find((p) => p.brand)?.brand || '';
|
|
414
|
+
const seeds = seedsIn && seedsIn.length ? seedsIn.map(clean).filter(Boolean) : seedsFromPages(pages, { host, brand: brandName, lang });
|
|
415
|
+
const anchors = anchorsFromPages(pages, { host, brand: brandName, lang });
|
|
416
|
+
for (const sd of seedsIn || []) for (const t of tokens(sd, lang)) anchors.add(t);
|
|
417
|
+
log(` зёрна (${seeds.length}): ${seeds.join('; ')}`);
|
|
418
|
+
log(` якорные слова темы (${anchors.size}): ${[...anchors].join(', ')}`);
|
|
419
|
+
const exp = await expandQueries(seeds, { lang, region, max, fetchJson, engines, anchors, delayMs: delayMs ?? DEMAND_DEFAULTS.delayMs });
|
|
420
|
+
log(` запросов: ${exp.queries.length} из ${exp.calls} обращений к подсказкам, не ответили ${exp.failed}`);
|
|
421
|
+
const fromSitemap = await sitemapPages(url, { fetchText }).catch(() => []);
|
|
422
|
+
const known = new Set((pagesIn || []).map((p) => p.url));
|
|
423
|
+
const extra = fromSitemap.filter((p) => !known.has(p.url));
|
|
424
|
+
const titled = titles ? await enrichTitles(extra, { fetchText, max: titles }) : 0;
|
|
425
|
+
log(` страниц для сопоставления: ${(pagesIn || []).length + extra.length}, заголовков дочитано ${titled}`);
|
|
426
|
+
const sitePages = [...(pagesIn || []), ...extra];
|
|
427
|
+
const cov = coverage(exp.queries, sitePages, { lang });
|
|
428
|
+
let vol = { source: 'none', engine: null, type: null, volumes: {}, price: null, currency: 'RUB', filled: 0, asked: exp.queries.length, note: volumes ? 'no credentials' : 'volumes off', projectId: null, groupId: null };
|
|
429
|
+
if (volumes && exp.queries.length) vol = await volumesViaTopvisor(exp.queries, { lang, region, client, dry, log, pollMs, timeoutMs, now, groupName: groupName || `${host} ${new Date().toISOString().slice(0, 10)}` });
|
|
430
|
+
const queries = cov.map((c) => ({ q: c.q, volume: vol.volumes[c.q.toLowerCase()] ?? null, covered: c.covered, url: c.url }))
|
|
431
|
+
.sort((a, b) => ((b.volume ?? -1) - (a.volume ?? -1)) || a.q.localeCompare(b.q));
|
|
432
|
+
return {
|
|
433
|
+
host, url, lang: lang === 'ru' ? 'ru' : 'en', region, regionName: REGIONS[region]?.[lang === 'ru' ? 'ru' : 'en'] || String(region), collectedAt: new Date().toISOString(),
|
|
434
|
+
seeds, anchors: [...anchors], discovery: { calls: exp.calls, failed: exp.failed, engines: exp.engines }, pagesIndexed: sitePages.length, titlesRead: titled,
|
|
435
|
+
volumes: { source: vol.source, engine: vol.engine, type: vol.type, price: vol.price, currency: vol.currency || 'RUB', filled: vol.filled, asked: vol.asked, note: vol.note || '', projectId: vol.projectId, groupId: vol.groupId },
|
|
436
|
+
queries, totals: demandTotals(queries),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
441
|
+
const fmt = (n, lang) => Number(n).toLocaleString(lang === 'ru' ? 'ru-RU' : 'en-US');
|
|
442
|
+
const pathOf = (u) => { try { const x = new URL(u); return x.pathname + x.search; } catch { return String(u || ''); } };
|
|
443
|
+
|
|
444
|
+
const COPY = {
|
|
445
|
+
ru: {
|
|
446
|
+
title: 'Карта спроса', eyebrow: 'Спрос',
|
|
447
|
+
lead: (d) => `Что люди в этой нише спрашивают на самом деле и есть ли у сайта страница под каждый вопрос. Запросы взяты из подсказок ${d.discovery?.engines?.includes('yandex') ? 'Яндекса и Google' : 'Google'}: это фразы, которые люди уже набирали. ${d.volumes.source === 'Topvisor' ? `Частота это сколько раз в месяц запрос задают в регионе «${d.regionName}», по данным Topvisor.` : 'Частоты для этой карты не собирались, поэтому доли считаются по числу запросов.'} Столбец «Страница» показывает, нашлась ли на сайте страница, в адресе или заголовке которой есть все слова запроса: это оценка по словам, а не чтение смысла.`,
|
|
448
|
+
cells: { queries: 'Запросов', covered: 'Есть страница', uncovered: 'Нет страницы', share: 'Спрос без страницы' },
|
|
449
|
+
verdict: (t) => (t.volumeTotal > 0
|
|
450
|
+
? `Под ${t.uncovered} запросов из ${t.queries} у сайта нет страницы. По частотам это ${Math.round(t.volumeUncovered / t.volumeTotal * 100)} % спроса ниши (${fmt(t.volumeUncovered, 'ru')} из ${fmt(t.volumeTotal, 'ru')} запросов в месяц), который сейчас проходит мимо сайта.`
|
|
451
|
+
: `Под ${t.uncovered} запросов из ${t.queries} у сайта нет страницы: ${t.queries ? Math.round(t.uncovered / t.queries * 100) : 0} % найденных вопросов ниши.`),
|
|
452
|
+
cols: { q: 'Запрос', v: 'В месяц', p: 'Страница' }, noVolume: 'нет данных', noPage: 'нет',
|
|
453
|
+
more: (n) => `Ещё ${n} запросов в приложении к отчёту (файл карты спроса).`,
|
|
454
|
+
empty: 'Подсказки не дали ни одного запроса по заголовкам этого сайта. Так бывает у сайтов без содержательных заголовков: сначала нужны страницы с темами, потом карта.',
|
|
455
|
+
source: (d) => `Откуда числа. Запросы: автодополнение ${d.discovery?.engines?.includes('yandex') ? 'Яндекса и Google' : 'Google'}, бесплатно, без аккаунта. ${d.volumes.source === 'Topvisor' ? 'Частоты: Topvisor, платный инструмент OperStack; в баллы отчёта они не входят. ' : ''}Проверить самому и бесплатно: Яндекс Wordstat (wordstat.yandex.ru) и Планировщик ключевых слов Google Ads, со своим бесплатным аккаунтом. Числа там могут отличаться на округление и дату сбора.`,
|
|
456
|
+
},
|
|
457
|
+
en: {
|
|
458
|
+
title: 'Demand map', eyebrow: 'Demand',
|
|
459
|
+
lead: (d) => `What people in this niche actually ask, and whether the site has a page for each question. The queries come from ${d.discovery?.engines?.includes('yandex') ? 'Yandex and Google' : 'Google'} autocomplete: phrases people have already typed. ${d.volumes.source === 'Topvisor' ? `Volume is how many times a month the query is searched in ${d.regionName}, according to Topvisor.` : 'Volumes were not collected for this map, so the shares are counted by number of queries.'} The "Page" column says whether a page on the site carries every word of the query in its address or title: a word match, not a reading of meaning.`,
|
|
460
|
+
cells: { queries: 'Queries', covered: 'Has a page', uncovered: 'No page', share: 'Demand without a page' },
|
|
461
|
+
verdict: (t) => (t.volumeTotal > 0
|
|
462
|
+
? `${t.uncovered} of ${t.queries} queries have no page on the site. By volume that is ${Math.round(t.volumeUncovered / t.volumeTotal * 100)}% of the niche's demand (${fmt(t.volumeUncovered, 'en')} of ${fmt(t.volumeTotal, 'en')} searches a month) passing the site by.`
|
|
463
|
+
: `${t.uncovered} of ${t.queries} queries have no page on the site: ${t.queries ? Math.round(t.uncovered / t.queries * 100) : 0}% of the questions found in the niche.`),
|
|
464
|
+
cols: { q: 'Query', v: 'A month', p: 'Page' }, noVolume: 'no data', noPage: 'none',
|
|
465
|
+
more: (n) => `${n} more queries in the report appendix (the demand map file).`,
|
|
466
|
+
empty: 'Autocomplete returned no queries for this site\'s headings. That happens on sites without descriptive headings: pages with topics come first, the map after.',
|
|
467
|
+
source: (d) => `Where the numbers come from. Queries: ${d.discovery?.engines?.includes('yandex') ? 'Yandex and Google' : 'Google'} autocomplete, free, no account. ${d.volumes.source === 'Topvisor' ? 'Volumes: Topvisor, a paid tool OperStack pays for; they count towards no score in this report. ' : ''}Check it yourself for free: Yandex Wordstat (wordstat.yandex.ru) and the Google Ads Keyword Planner, with your own free account. Their figures may differ by rounding and by the date of collection.`,
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
/** Тело раздела для отчёта: цифры, вывод, таблица, откуда данные. Обёртку страницы даёт отчёт. */
|
|
472
|
+
export function renderDemandHtml(d, { lang = d.lang, limit = 40 } = {}) {
|
|
473
|
+
const L = COPY[lang === 'ru' ? 'ru' : 'en'];
|
|
474
|
+
const t = d.totals || demandTotals(d.queries || []);
|
|
475
|
+
if (!t.queries) return `<p>${esc(L.lead(d))}</p><p>${esc(L.empty)}</p><p class="scorecard-foot">${esc(L.source(d))}</p>`;
|
|
476
|
+
const share = t.volumeTotal > 0 ? `${Math.round(t.volumeUncovered / t.volumeTotal * 100)} %` : `${Math.round(t.uncovered / t.queries * 100)} %`;
|
|
477
|
+
const cells = [[L.cells.queries, t.queries], [L.cells.covered, t.covered], [L.cells.uncovered, t.uncovered], [L.cells.share, share]]
|
|
478
|
+
.map(([k, v]) => `<div class="kpi"><div class="kpi-value">${esc(v)}</div><div class="kpi-label">${esc(k)}</div></div>`).join('');
|
|
479
|
+
const rows = (d.queries || []).slice(0, limit).map((q) => `<tr><td>${esc(q.q)}</td><td class="num">${q.volume === null || q.volume === undefined ? esc(L.noVolume) : fmt(q.volume, lang)}</td><td>${q.covered ? esc(pathOf(q.url)) : `<span class="status-bad">${esc(L.noPage)}</span>`}</td></tr>`).join('');
|
|
480
|
+
const rest = Math.max(0, (d.queries || []).length - limit);
|
|
481
|
+
return `<p>${esc(L.lead(d))}</p><div class="kpis">${cells}</div><div class="verdict"><p><strong>${esc(L.verdict(t))}</strong></p></div><table><tr><th>${esc(L.cols.q)}</th><th class="num">${esc(L.cols.v)}</th><th>${esc(L.cols.p)}</th></tr>${rows}</table>${rest ? `<p class="scorecard-foot">${esc(L.more(rest))}</p>` : ''}<p class="scorecard-foot">${esc(L.source(d))}</p>`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Та же карта текстом: для файла рядом с отчётом и для письма. */
|
|
485
|
+
export function renderDemandMarkdown(d, { lang = d.lang, limit = 200 } = {}) {
|
|
486
|
+
const L = COPY[lang === 'ru' ? 'ru' : 'en'];
|
|
487
|
+
const t = d.totals || demandTotals(d.queries || []);
|
|
488
|
+
const head = [`# ${L.title}: ${d.host}`, '', L.lead(d), ''];
|
|
489
|
+
if (!t.queries) return [...head, L.empty, '', L.source(d)].join('\n');
|
|
490
|
+
const table = [`| ${L.cols.q} | ${L.cols.v} | ${L.cols.p} |`, '|---|---:|---|', ...(d.queries || []).slice(0, limit).map((q) => `| ${q.q} | ${q.volume === null || q.volume === undefined ? L.noVolume : fmt(q.volume, lang)} | ${q.covered ? pathOf(q.url) : L.noPage} |`)];
|
|
491
|
+
return [...head, `**${L.verdict(t)}**`, '', ...table, '', L.source(d)].join('\n');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Строка для таблицы источников отчёта: карта спроса названа платной и не входящей в балл. */
|
|
495
|
+
export function demandSourceRow(d, lang = d.lang) {
|
|
496
|
+
const ru = lang === 'ru';
|
|
497
|
+
if (d.volumes?.source === 'Topvisor') {
|
|
498
|
+
return { name: ru ? 'Topvisor (частоты для карты спроса)' : 'Topvisor (volumes for the demand map)', access: ru ? 'Платный инструмент, в баллы не входит' : 'Paid tool, counts towards no score', detail: ru ? `частоты ${d.totals?.withVolume ?? 0} запросов в регионе «${d.regionName}»; сами запросы из бесплатных подсказок` : `volumes for ${d.totals?.withVolume ?? 0} queries in ${d.regionName}; the queries themselves come from free autocomplete` };
|
|
499
|
+
}
|
|
500
|
+
return { name: ru ? 'Подсказки поиска (карта спроса)' : 'Search autocomplete (demand map)', access: ru ? 'Бесплатно, без аккаунта' : 'Free, no account needed', detail: ru ? `${d.totals?.queries ?? 0} запросов; частоты не собирались` : `${d.totals?.queries ?? 0} queries; no volumes collected` };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function demandCsv(d) {
|
|
504
|
+
const line = (a) => a.map((x) => `"${String(x ?? '').replace(/"/g, '""')}"`).join(',');
|
|
505
|
+
return [line(['query', 'volume', 'has_page', 'url']), ...(d.queries || []).map((q) => line([q.q, q.volume ?? '', q.covered ? 1 : 0, q.url || '']))].join('\n');
|
|
506
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -6,4 +6,5 @@ export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18
|
|
|
6
6
|
export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
|
|
7
7
|
export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
|
|
8
8
|
export { draftNarrative, stillEmpty } from './narrative.mjs';
|
|
9
|
-
export { agentPrompt, agentPrompts, renderAgentPrompts } from './prompts.mjs';
|
|
9
|
+
export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
10
|
+
export { buildDemandMap, expandQueries, coverage, seedsFromPages, volumesViaTopvisor, topvisorClient, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, REGIONS as DEMAND_REGIONS, DEMAND_DEFAULTS } from './demand.mjs';
|
package/src/prompts.mjs
CHANGED
|
@@ -174,3 +174,84 @@ export function renderAgentPrompts(audit, opts = {}) {
|
|
|
174
174
|
'You do not need to know any code. Your job is to say what to do and look at the result.', ''];
|
|
175
175
|
return [intro.join('\n').trimEnd(), ...list.map((x) => x.text)].join('\n\n');
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Первая правка на бесплатной странице тремя частями: что сейчас, что поменять, как проверить.
|
|
180
|
+
*
|
|
181
|
+
* Зачем: бесплатная проверка (`checkVisibility`) отдаёт находки как { id, area, text }, а
|
|
182
|
+
* задания выше написаны по строкам аудита с другими ключами (`llms` против `llms-missing`).
|
|
183
|
+
* Страница показывала только текст находки, а три части приходили лишь письмом. Здесь каждая
|
|
184
|
+
* находка бесплатной проверки сопоставлена с заданием явно, по одному ключу на id; для находок,
|
|
185
|
+
* у которых задания в наборе нет, текст написан тут же. Неизвестный id даёт null: страница
|
|
186
|
+
* тогда честно показывает одну находку, а не чужое указание по чужому сайту.
|
|
187
|
+
*
|
|
188
|
+
* «Сейчас» это сам текст находки: он уже измерен и назван, второй раз не формулируем.
|
|
189
|
+
*/
|
|
190
|
+
const FIRST_FIX_BY_PROMPT = {
|
|
191
|
+
'robots-all-blocked': 'robots',
|
|
192
|
+
'robots-fetchers-blocked': 'ai-search-access',
|
|
193
|
+
'llms-missing': 'llms',
|
|
194
|
+
'canonical-missing': 'canonical',
|
|
195
|
+
'og-missing': 'og',
|
|
196
|
+
'schema-org-missing': 'schema',
|
|
197
|
+
'schema-faq-missing': 'faq-schema',
|
|
198
|
+
'dates-missing': 'dates',
|
|
199
|
+
'answer-first-missing': 'answer-first',
|
|
200
|
+
'sources-missing': 'sources',
|
|
201
|
+
'thin-pages': 'thin',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const FIRST_FIX_OWN = {
|
|
205
|
+
'robots-training-blocked': {
|
|
206
|
+
ru: { task: 'Реши, хочешь ли ты, чтобы модели ИИ учились на сайте. Если да, убери из robots.txt запреты для обучающих роботов, которые названы в строке «Сейчас». Если нет, оставь как есть: это осознанный выбор, а не ошибка, и балл он снижает немного.', verify: 'В robots.txt для названных обучающих роботов нет строки Disallow, либо решение закрыть их записано владельцем словами.' },
|
|
207
|
+
en: { task: 'Decide whether you want AI models to learn from the site. If yes, remove the robots.txt rules that block the training crawlers named under Now. If no, leave it: that is a deliberate choice, not a mistake, and it costs only a little of the score.', verify: 'robots.txt has no Disallow for the training crawlers named, or the decision to keep them out is written down by the owner.' },
|
|
208
|
+
},
|
|
209
|
+
'noai-meta': {
|
|
210
|
+
ru: { task: 'На одной из страниц стоит мета-тег robots со значением noai: он просит системы ИИ не использовать содержимое. Если хочешь, чтобы сайт цитировали в ответах, убери это значение из шаблона страниц. Если это осознанный запрет, оставь.', verify: 'В HTML проверенных страниц нет мета-тега robots со значением noai.' },
|
|
211
|
+
en: { task: 'One of the pages carries a robots meta tag with the value noai: it asks AI systems not to use the content. If you want the site quoted in answers, remove that value from the page template. If the ban is deliberate, leave it.', verify: 'The HTML of the sampled pages has no robots meta tag with the value noai.' },
|
|
212
|
+
},
|
|
213
|
+
'llms-foreign': {
|
|
214
|
+
ru: { task: 'Перепиши llms.txt так, чтобы ссылки в нём вели на страницы этого сайта, а не на чужие домены. Чужую ссылку оставь только там, где без неё нельзя (например, профиль в каталоге), и не в начале файла.', verify: 'Большинство ссылок в llms.txt ведут на этот же домен, и первые из них тоже.' },
|
|
215
|
+
en: { task: 'Rewrite llms.txt so its links point at pages of this site rather than other domains. Keep an outside link only where it cannot be avoided (a directory profile, say), and never at the top of the file.', verify: 'Most links in llms.txt point at this domain, and so do the first ones.' },
|
|
216
|
+
},
|
|
217
|
+
'llms-not-text': {
|
|
218
|
+
ru: { task: 'Адрес /llms.txt отдаёт HTML-страницу вместо текста. Сделай так, чтобы по этому адресу отдавался сам текстовый файл с типом text/plain, а не страница сайта и не заглушка «не найдено» с кодом 200.', verify: '/llms.txt отвечает кодом 200 с типом text/plain, и в ответе текстовый указатель, а не разметка HTML.' },
|
|
219
|
+
en: { task: '/llms.txt serves an HTML page instead of text. Make that address return the text file itself with the type text/plain, not a site page and not a "not found" page with a 200 status.', verify: '/llms.txt answers 200 with text/plain, and the body is a text index rather than HTML markup.' },
|
|
220
|
+
},
|
|
221
|
+
'schema-article-missing': {
|
|
222
|
+
ru: { task: 'Добавь на страницы статей и записей разметку Article или BlogPosting в JSON-LD: заголовок, дата публикации, дата изменения, автор, издатель. Бери данные со страницы, ничего не придумывай.', verify: 'На странице статьи есть JSON-LD с типом Article или BlogPosting, и проверка разметки не показывает ошибок.' },
|
|
223
|
+
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.' },
|
|
224
|
+
},
|
|
225
|
+
'few-h2': {
|
|
226
|
+
ru: { task: 'Разбей длинные страницы на разделы: каждый начинается заголовком H2, который звучит как вопрос или тема раздела. На странице должно быть не меньше трёх таких разделов. Ради объёма ничего не дописывай, только структурируй то, что есть.', verify: 'На каждой проверенной странице не меньше трёх заголовков H2, и по ним понятно, о чём страница, без чтения текста.' },
|
|
227
|
+
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.' },
|
|
228
|
+
},
|
|
229
|
+
'no-tables': {
|
|
230
|
+
ru: { task: 'Там, где на странице есть цифры, которые сравнивают (цены, сроки, площади, условия), собери их в таблицу с подписанными столбцами. Строй её только из данных, которые уже есть на странице или у владельца.', verify: 'На страницах со сравниваемыми цифрами есть хотя бы одна настоящая таблица (тег table), а не картинка и не список.' },
|
|
231
|
+
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.' },
|
|
232
|
+
},
|
|
233
|
+
'sitemap-no-lastmod': {
|
|
234
|
+
ru: { task: 'Проставь в карте сайта (sitemap.xml) каждой странице поле lastmod с настоящей датой последнего изменения содержимого. Дату сборки или сегодняшнее число во все строки не ставь: это хуже, чем пустое поле.', verify: 'У каждого url в sitemap.xml есть lastmod, даты различаются между страницами и совпадают с датой изменения на самой странице.' },
|
|
235
|
+
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.' },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Все id находок бесплатной проверки, для которых есть три части. */
|
|
240
|
+
export const FIRST_FIX_IDS = [...Object.keys(FIRST_FIX_BY_PROMPT), ...Object.keys(FIRST_FIX_OWN)];
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {{id?: string, area?: string, text?: string}} finding элемент `fixes[]` из `checkVisibility`
|
|
244
|
+
* @param {{lang?: 'ru'|'en'}} opts
|
|
245
|
+
* @returns {{now: string, task: string, verify: string, rule: string, labels: {now: string, task: string, verify: string}}|null}
|
|
246
|
+
*/
|
|
247
|
+
export function firstFixParts(finding, opts = {}) {
|
|
248
|
+
const lang = opts.lang === 'ru' ? 'ru' : 'en';
|
|
249
|
+
const h = HEAD[lang];
|
|
250
|
+
const id = finding && finding.id;
|
|
251
|
+
const now = String((finding && finding.text) || '').trim();
|
|
252
|
+
if (!id || !now) return null;
|
|
253
|
+
const own = FIRST_FIX_OWN[id] ? withLang(FIRST_FIX_OWN[id], lang)
|
|
254
|
+
: (FIRST_FIX_BY_PROMPT[id] && P[FIRST_FIX_BY_PROMPT[id]] ? withLang(P[FIRST_FIX_BY_PROMPT[id]], lang) : null);
|
|
255
|
+
if (!own || !own.task || !own.verify) return null;
|
|
256
|
+
return { now, task: own.task, verify: own.verify, rule: h.rule, labels: { now: h.now, task: h.task, verify: h.verify } };
|
|
257
|
+
}
|
package/src/render.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { computeScores, computeOverall } from './collect.mjs';
|
|
|
11
11
|
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
12
12
|
import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
|
|
13
13
|
import { softenHex } from './agency.mjs';
|
|
14
|
+
import { renderDemandHtml, demandSourceRow } from './demand.mjs';
|
|
14
15
|
|
|
15
16
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
16
17
|
const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
|
|
@@ -53,7 +54,7 @@ const LABELS = {
|
|
|
53
54
|
secOffpage: 'Off-page, conversion, limitations', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '09 · Limitations', sourcesTitle: 'Where the data comes from', colSource: 'Source', colAccess: 'Access', colWhatRead: 'What was read',
|
|
54
55
|
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same numbers.',
|
|
55
56
|
limitations: 'Limitations',
|
|
56
|
-
secRoadmap: 'Roadmap', roadmapEyebrow: '10 · Roadmap', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
57
|
+
secRoadmap: 'Roadmap', roadmapEyebrow: '10 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
57
58
|
runningHead: 'SEO, AEO and GEO audit',
|
|
58
59
|
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
59
60
|
areas: null,
|
|
@@ -93,7 +94,7 @@ const LABELS = {
|
|
|
93
94
|
secOffpage: 'Ссылки, конверсия, ограничения', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '09 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
94
95
|
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же цифры.',
|
|
95
96
|
limitations: 'Ограничения',
|
|
96
|
-
secRoadmap: 'План работ', roadmapEyebrow: '10 · План', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
97
|
+
secRoadmap: 'План работ', roadmapEyebrow: '10 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
97
98
|
runningHead: 'Аудит SEO, AEO и GEO',
|
|
98
99
|
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
99
100
|
areas: AREAS_RU,
|
|
@@ -214,7 +215,12 @@ function css() {
|
|
|
214
215
|
table { width: 100%; border-collapse: collapse; margin: 3mm 0 5mm; font-size: 9.2pt; }
|
|
215
216
|
th { text-align: left; padding: 2.5mm 3mm; font-size: 7.8pt; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border-bottom: 2px solid var(--ink); font-weight: 600; }
|
|
216
217
|
td { padding: 2.2mm 3mm; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
|
217
|
-
.status-ok { color: var(--ok); font-weight: 600; white-space: nowrap; } .status-warn { color: var(--warn); font-weight: 600; white-space: nowrap; } .status-bad { color: var(--danger); font-weight: 600; white-space: nowrap; }
|
|
218
|
+
.status-ok { color: var(--ok); font-weight: 600; white-space: nowrap; } .status-warn { color: var(--warn); font-weight: 600; white-space: nowrap; } .status-bad { color: var(--danger); font-weight: 600; white-space: nowrap; }
|
|
219
|
+
.kpis { display: flex; gap: 4mm; margin: 4mm 0 5mm; }
|
|
220
|
+
.kpi { flex: 1; background: var(--panel); border-radius: 4px; padding: 3mm 4mm; }
|
|
221
|
+
.kpi-value { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-size: 17pt; font-weight: 600; color: var(--ink); line-height: 1.1; }
|
|
222
|
+
.kpi-label { font-size: 8.5pt; color: var(--muted); margin-top: 1mm; }
|
|
223
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); }
|
|
218
224
|
.cards { display: grid; gap: 3mm; margin: 4mm 0; }
|
|
219
225
|
.card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
|
|
220
226
|
.card.warn { border-left-color: var(--warn); background: #fdf8ee; }
|
|
@@ -299,9 +305,15 @@ export function toHtml(audit, brand = null) {
|
|
|
299
305
|
const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment.charAt(0).toUpperCase() + c.comment.slice(1))}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
|
|
300
306
|
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">Whether the site's answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.</p><div class="two-col"><div><h3>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div><h2>${L.geoTitle}</h2><table><tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++, preparedBy)}</div>`);
|
|
301
307
|
const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('')}</table>` : '');
|
|
302
|
-
|
|
308
|
+
const sources = [...(a.sources || []), ...(a.demand && Array.isArray(a.demand.queries) ? [demandSourceRow(a.demand, a.meta?.lang === 'ru' ? 'ru' : 'en')] : [])];
|
|
309
|
+
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><div class="eyebrow" style="margin-top:8mm">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${sources.length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</th></tr>${sources.map((s) => `<tr><td>${t(s.name)}</td><td>${t(s.access)}</td><td>${t(s.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}${footer(a, n++, preparedBy)}</div>`);
|
|
303
310
|
const phases = (a.roadmap || []).map((p) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(p.badge)}</span><span class="phase-title">${t(p.title)}</span></div>${olist(p.items)}</div>`).join('');
|
|
304
311
|
pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div>${footer(a, n++, preparedBy)}</div>`);
|
|
312
|
+
// Карта спроса идёт приложением после дорожной карты: она не входит в баллы, и об этом
|
|
313
|
+
// сказано в ней самой и в таблице источников выше.
|
|
314
|
+
if (a.demand && Array.isArray(a.demand.queries)) {
|
|
315
|
+
pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
|
|
316
|
+
}
|
|
305
317
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: SEO, AEO and GEO audit</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
|
|
306
318
|
}
|
|
307
319
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Карта спроса: зёрна, подсказки, частоты, покрытие, вывод.
|
|
4
|
+
*
|
|
5
|
+
* Здесь держится пять вещей. Зёрна берутся из заголовков без слов бренда. Подсказки
|
|
6
|
+
* фильтруются по общему слову с зерном и делятся поровну между зёрнами. Клиент Topvisor
|
|
7
|
+
* ходит по методам в правильном порядке, спрашивает цену до запуска и в сухом прогоне не
|
|
8
|
+
* запускает ничего. Покрытие считается по словам адреса и заголовка. В выводе нет ни null,
|
|
9
|
+
* ни undefined, ни нуля вместо «нет данных», и доли сходятся с таблицей.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { toHtml } from './render.mjs';
|
|
14
|
+
import { verifyScores, computeScores } from './collect.mjs';
|
|
15
|
+
import { anchorsFromPages, enrichTitles, tokens, seedsFromPages, expandQueries, coverage, sitemapPages, topvisorClient, volumesViaTopvisor, buildDemandMap, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, PATTERNS, REGIONS } from './demand.mjs';
|
|
16
|
+
|
|
17
|
+
let bad = 0;
|
|
18
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
19
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
20
|
+
|
|
21
|
+
// ---- слова
|
|
22
|
+
is('основа слова: квартира и квартиры совпадают', [...tokens('квартиру в сочи', 'ru')].sort(), [...tokens('квартира сочи', 'ru')].sort());
|
|
23
|
+
is('кириллица и латинский адрес сходятся к одной основе', tokens('квартиры у моря', 'ru'), tokens('kvartiry u morya', 'ru'));
|
|
24
|
+
is('варианты транслитерации схлопнуты', tokens('площадь хутор цена', 'ru'), tokens('ploshchad khutor', 'ru'));
|
|
25
|
+
ok('служебные слова выпадают', !tokens('как купить квартиру в сочи', 'ru').includes('как'));
|
|
26
|
+
ok('английские тоже, и «buy» не различает страницы', JSON.stringify(tokens('how to buy a condo in miami', 'en')) === JSON.stringify(['condo', 'miami']));
|
|
27
|
+
is('ё и е одно и то же', tokens('ещё жильё', 'ru'), tokens('еще жилье', 'ru'));
|
|
28
|
+
|
|
29
|
+
// ---- зёрна
|
|
30
|
+
{
|
|
31
|
+
const pages = [
|
|
32
|
+
{ url: 'https://invest-cambodia.com/', title: 'Invest Cambodia | Real estate in Phnom Penh', h1: 'Real estate in Phnom Penh' },
|
|
33
|
+
{ url: 'https://invest-cambodia.com/visa/', title: 'Cambodia retirement visa: rules 2026 - Invest Cambodia', h1: 'Cambodia retirement visa' },
|
|
34
|
+
{ url: 'https://invest-cambodia.com/contact/', title: 'Contact us', h1: 'Contact us' },
|
|
35
|
+
];
|
|
36
|
+
const s = seedsFromPages(pages, { host: 'www.invest-cambodia.com' });
|
|
37
|
+
ok('бренд вырезан из зёрен', !s.some((x) => /invest/.test(x)) && s.includes('real estate in phnom penh'));
|
|
38
|
+
ok('заголовок разбит по разделителям', s.includes('retirement visa: rules 2026'.replace(': rules 2026', '')) || s.includes('retirement visa') || s.includes('cambodia retirement visa'));
|
|
39
|
+
ok('«contact us» не зерно', !s.includes('contact us'));
|
|
40
|
+
ok('дубли схлопнуты', new Set(s).size === s.length);
|
|
41
|
+
is('лимит зёрен работает', seedsFromPages(pages, { host: 'x.com', max: 1 }).length, 1);
|
|
42
|
+
// Главная MORE Group 15.09.2026: заголовок с брендом и сроком, H1 с ценой, меню из кнопок.
|
|
43
|
+
const mg = seedsFromPages([{
|
|
44
|
+
url: 'https://moregroupestate.ru/', title: 'MORE Group: агентство недвижимости на Пхукете. Подбор объекта за 2 часа', h1: 'Новые проекты на Пхукете от 4,6 млн ₽',
|
|
45
|
+
h2: ['Получите подборку под ваш бюджет', 'Районы Пхукета', 'Калькулятор доходности', 'Почему инвестировать в Пхукет?'],
|
|
46
|
+
nav: ['MORE Group', 'О компании', 'Подборка за 2 часа', 'Получить подборку за 2 часа', 'M MAX без VPN', 'TG Telegram', 'WA WhatsApp'],
|
|
47
|
+
}], { host: 'moregroupestate.ru', brand: 'MORE Group' });
|
|
48
|
+
is('H1 без цены идёт первым', mg[0], 'новые проекты на пхукете');
|
|
49
|
+
ok('тема из заголовка после двоеточия', mg.includes('агентство недвижимости на пхукете'));
|
|
50
|
+
ok('подзаголовки дают темы, если держатся за якорь', mg.includes('районы пхукета') && mg.includes('почему инвестировать в пхукет'));
|
|
51
|
+
ok('подзаголовок без якоря (лозунг) не зерно', !mg.includes('гибкие планы оплаты') && !mg.includes('калькулятор доходности'));
|
|
52
|
+
ok('кнопки, мессенджеры и бренд не зёрна', !mg.some((x) => /подборк|telegram|whatsapp|vpn|more group|о компании/.test(x)));
|
|
53
|
+
const an = anchorsFromPages([{ title: 'MORE Group: агентство недвижимости на Пхукете', h1: 'Новые проекты на Пхукете от 4,6 млн ₽' }], { host: 'moregroupestate.ru', brand: 'MORE Group', lang: 'ru' });
|
|
54
|
+
ok('якоря: тема без бренда, чисел и единиц', an.has('phuke') && an.has('nedvi') && !an.has('more') && !an.has('group') && !an.has('mln') && ![...an].some((t) => /\d/.test(t)));
|
|
55
|
+
const an2 = anchorsFromPages([{ title: 'MORE Group: агентство недвижимости на Пхукете. Подбор объекта за 2 часа', h1: '' }], { host: 'moregroupestate.ru', brand: 'MORE Group', lang: 'ru' });
|
|
56
|
+
ok('срок из заголовка («за 2 часа») не якорь', !an2.has('chasa') && an2.has('podbo'));
|
|
57
|
+
const cam = seedsFromPages([{ title: 'Invest Cambodia: property investment in Phnom Penh', h1: 'Property investment for foreign buyers', h2: ['What changed', 'Who should not buy here', 'Areas of Phnom Penh'], nav: ['Get your shortlist'] }], { host: 'invest-cambodia.com', lang: 'en' });
|
|
58
|
+
ok('лозунги главной не зёрна, а раздел про районы да', !cam.includes('what changed') && !cam.includes('who should not buy here') && cam.includes('areas of phnom penh'));
|
|
59
|
+
const camAn = anchorsFromPages([{ title: 'Property, measured before it is sold', h1: 'Property investment for foreign buyers' }], { host: 'invest-cambodia.com', lang: 'en' });
|
|
60
|
+
ok('география из имени домена стала якорем, а общее слово «invest» нет', camAn.has('cambo') && !camAn.has('inves'));
|
|
61
|
+
const mgAn = anchorsFromPages([{ title: 'MORE Group: агентство недвижимости на Пхукете', h1: '' }], { host: 'moregroupestate.ru', brand: 'MORE Group', lang: 'ru' });
|
|
62
|
+
ok('склеенное имя домена якорем не стало', !mgAn.has('moreg'));
|
|
63
|
+
const rep = seedsFromPages([{ title: 'Виллы на Пхукете | Юг-Дом', h1: '' }, { title: 'Дома у моря | Юг-Дом', h1: '' }], { host: 'x.ru' });
|
|
64
|
+
ok('повторяющийся сегмент заголовка признан брендом', rep.includes('виллы на пхукете') && !rep.some((x) => /юг-дом/.test(x)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- подсказки с поддельным автодополнением
|
|
68
|
+
{
|
|
69
|
+
const fake = async (url) => {
|
|
70
|
+
const q = decodeURIComponent((url.match(/[?&](?:q|part)=([^&]*)/) || [])[1] || '');
|
|
71
|
+
if (/сломан/.test(q)) throw new Error('boom');
|
|
72
|
+
const base = q.replace(/^(как|где|что такое|сколько стоит)\s+/, '').replace(/\s+(цена|отзывы|или)$/, '');
|
|
73
|
+
return [q, [`${base} недорого`, `${base} у моря`, 'совсем другое', 'один', `${q} 2026`]];
|
|
74
|
+
};
|
|
75
|
+
const r = await expandQueries(['квартира в сочи', 'дом в сломанном месте'], { lang: 'ru', region: 225, max: 6, delayMs: 0, fetchJson: fake, engines: ['yandex'] });
|
|
76
|
+
is('обращений столько, сколько шаблонов на зерно', r.calls, PATTERNS.ru.length * 2);
|
|
77
|
+
ok('падение одного зерна не роняет прогон', r.failed === PATTERNS.ru.length && r.queries.length > 0);
|
|
78
|
+
ok('чужие фразы отфильтрованы по общему слову', !r.queries.includes('совсем другое'));
|
|
79
|
+
ok('фразы из одного слова выпали', !r.queries.includes('один'));
|
|
80
|
+
ok('лимит соблюдён', r.queries.length <= 6);
|
|
81
|
+
ok('само зерно в списке первым', r.queries[0] === 'квартира в сочи');
|
|
82
|
+
const two = await expandQueries(['alpha beta', 'gamma delta'], { lang: 'en', region: 84, max: 4, delayMs: 0, engines: ['google'], fetchJson: async (u) => { const q = decodeURIComponent((u.match(/q=([^&]*)/) || [])[1]); const b = q.replace(/^(how to|what is|best)\s+/, '').replace(/\s+(cost|price|near me|vs)$/, ''); return [q, [`${b} one`, `${b} two`, `${b} three`]]; } });
|
|
83
|
+
ok('квота делится между зёрнами поровну', two.queries.filter((q) => q.startsWith('alpha')).length === 2 && two.queries.filter((q) => q.startsWith('gamma')).length === 2);
|
|
84
|
+
const anchored = await expandQueries(['квартира в сочи'], { lang: 'ru', region: 225, max: 20, delayMs: 0, engines: ['yandex'], anchors: new Set(['sochi']), fetchJson: async () => ['x', ['квартира в сочи недорого', 'квартира в анапе недорого', 'квартиры сочи у моря']] });
|
|
85
|
+
ok('без якорного слова запрос выпадает, даже если делит слово с зерном', anchored.queries.includes('квартира в сочи недорого') && anchored.queries.includes('квартиры сочи у моря') && !anchored.queries.includes('квартира в анапе недорого'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- покрытие
|
|
89
|
+
{
|
|
90
|
+
const pages = [{ url: 'https://x.ru/kvartiry-v-sochi/', title: '', h1: '' }, { url: 'https://x.ru/blog/post-1/', title: 'Дом у моря: цены 2026', h1: 'Дом у моря' }];
|
|
91
|
+
const c = coverage(['купить квартиру в сочи', 'дом у моря цена', 'вилла в анапе'], pages, { lang: 'ru' });
|
|
92
|
+
ok('покрытие по латинскому адресу русского сайта', c[0].covered && c[0].url === 'https://x.ru/kvartiry-v-sochi/');
|
|
93
|
+
ok('покрытие по заголовку', c[1].covered && /post-1/.test(c[1].url));
|
|
94
|
+
ok('нет страницы, значит нет', !c[2].covered && c[2].url === null);
|
|
95
|
+
const en = coverage(['buy condo miami', 'phuket villa rental', 'rent condo miami'], [{ url: 'https://y.com/miami/condos-for-sale/', title: '', h1: '' }], { lang: 'en' });
|
|
96
|
+
ok('«купить» не отличает страницу: сайт продажи отвечает на запрос про покупку', en[0].covered && !en[1].covered);
|
|
97
|
+
ok('а «снять» отличает: страница продажи не про аренду', !en[2].covered);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- карты сайта
|
|
101
|
+
{
|
|
102
|
+
const files = {
|
|
103
|
+
'https://x.ru/robots.txt': 'User-agent: *\nSitemap: https://x.ru/sitemap-index.xml\n',
|
|
104
|
+
'https://x.ru/sitemap-index.xml': '<sitemapindex><sitemap><loc>https://x.ru/s1.xml</loc></sitemap><sitemap><loc>https://x.ru/s2.xml</loc></sitemap></sitemapindex>',
|
|
105
|
+
'https://x.ru/s1.xml': '<urlset><url><loc>https://x.ru/a/</loc></url><url><loc>https://x.ru/b/</loc></url></urlset>',
|
|
106
|
+
'https://x.ru/s2.xml': '<urlset><url><loc>https://x.ru/c/</loc></url><url><loc> https://x.ru/a/ </loc></url></urlset>',
|
|
107
|
+
};
|
|
108
|
+
const p = await sitemapPages('https://x.ru/', { fetchText: async (u) => { if (!files[u]) throw new Error('404'); return files[u]; } });
|
|
109
|
+
is('адреса из вложенных карт без дублей', p.map((x) => x.url), ['https://x.ru/a/', 'https://x.ru/b/', 'https://x.ru/c/']);
|
|
110
|
+
const none = await sitemapPages('https://y.ru/', { fetchText: async () => { throw new Error('404'); } });
|
|
111
|
+
is('без карты пустой список, не ошибка', none, []);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- заголовки для английских адресов русского сайта
|
|
115
|
+
{
|
|
116
|
+
let fetched = 0;
|
|
117
|
+
const html = { 'https://x.ru/districts/': '<title>Районы Пхукета: где купить | Юг-Дом</title><h1>Районы Пхукета</h1>', 'https://x.ru/districts/bang-tao/': '<title>Банг Тао</title>', 'https://x.ru/a/b/c/': '<title>Глубоко</title>' };
|
|
118
|
+
const pages = [{ url: 'https://x.ru/a/b/c/', title: '', h1: '' }, { url: 'https://x.ru/districts/bang-tao/', title: '', h1: '' }, { url: 'https://x.ru/districts/', title: '', h1: '' }, { url: 'https://x.ru/dead/', title: '', h1: '' }];
|
|
119
|
+
const done = await enrichTitles(pages, { max: 3, concurrency: 2, fetchText: async (u) => { fetched += 1; if (!html[u]) throw new Error('404'); return html[u]; } });
|
|
120
|
+
ok('короткие адреса читаются первыми, лимит соблюдён', fetched === 3 && done === 2 && pages.find((p) => p.url === 'https://x.ru/a/b/c/').title === '' && pages.find((p) => p.url === 'https://x.ru/districts/bang-tao/').title === 'Банг Тао');
|
|
121
|
+
ok('страница без ответа остаётся адресом, не ошибкой', pages.find((p) => p.url === 'https://x.ru/dead/').title === '');
|
|
122
|
+
const c = coverage(['районы пхукета'], pages, { lang: 'ru' });
|
|
123
|
+
ok('русский запрос нашёл страницу с английским адресом по заголовку', c[0].covered && c[0].url === 'https://x.ru/districts/');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- Topvisor на поддельном сервере
|
|
127
|
+
function fakeTopvisor() {
|
|
128
|
+
const state = { projects: [{ id: 1, name: 'Другой', site: 'z.ru' }], searchers: {}, groups: [], keywords: [], launched: 0, polls: 0, calls: [] };
|
|
129
|
+
const server = createServer((req, res) => {
|
|
130
|
+
let raw = ''; req.on('data', (d) => { raw += d; });
|
|
131
|
+
req.on('end', () => {
|
|
132
|
+
const method = req.url.replace(/^\//, '');
|
|
133
|
+
let body = {};
|
|
134
|
+
const ct = req.headers['content-type'] || '';
|
|
135
|
+
if (ct.startsWith('multipart/form-data')) {
|
|
136
|
+
const b = ct.split('boundary=')[1];
|
|
137
|
+
for (const part of raw.split(`--${b}`)) { const m = part.match(/name="([^"]+)"\r\n\r\n([\s\S]*?)\r\n$/); if (m) body[m[1]] = m[2]; }
|
|
138
|
+
} else { try { body = JSON.parse(raw || '{}'); } catch { body = {}; } }
|
|
139
|
+
state.calls.push({ method, body, auth: req.headers.authorization, uid: req.headers['user-id'] });
|
|
140
|
+
const send = (result) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ result })); };
|
|
141
|
+
const fail = (s) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ result: null, errors: [{ code: 2001, string: s }] })); };
|
|
142
|
+
if (method === 'get/projects_2/projects') {
|
|
143
|
+
const list = state.projects.filter((p) => !body.filters || body.filters.every((f) => f.values.includes(p.id)));
|
|
144
|
+
return send(body.show_searchers_and_regions ? list.map((p) => ({ ...p, searchers: Object.entries(state.searchers[p.id] || {}).map(([k, regs]) => ({ key: Number(k), regions: regs.map((r) => ({ key: r })) })) })) : list);
|
|
145
|
+
}
|
|
146
|
+
if (method === 'add/projects_2/projects') { state.projects.push({ id: 7, name: body.name, site: body.url }); return send(7); }
|
|
147
|
+
if (method === 'add/positions_2/searchers') { state.searchers[body.project_id] ||= {}; if (state.searchers[body.project_id][body.searcher_key]) return fail('Поисковая система уже добавлена'); state.searchers[body.project_id][body.searcher_key] = []; return send(1); }
|
|
148
|
+
if (method === 'add/positions_2/searchers/regions') { state.searchers[body.project_id][body.searcher_key].push(body.region_key); return send(1); }
|
|
149
|
+
if (method === 'add/keywords_2/groups') { if (!Array.isArray(body.name)) return fail('name must be array'); const id = 100 + state.groups.length; state.groups.push({ id, name: body.name[0], project_id: body.project_id }); return send([id]); }
|
|
150
|
+
if (method === 'get/keywords_2/groups') return send(state.groups.filter((g) => g.project_id === body.project_id));
|
|
151
|
+
if (method === 'add/keywords_2/keywords/import') { for (const k of String(body.keywords).split('\n')) state.keywords.push({ id: state.keywords.length + 1, name: k, group_id: Number(body.group_id) }); return send(state.keywords.length); }
|
|
152
|
+
if (method === 'get/keywords_2/volumes/price') { const n = state.keywords.filter((k) => k.group_id === body.filters[0].values[0]).length; return send({ pricesByUsers: { 1: { price: Math.round(n * 2) / 100, limits: n } } }); }
|
|
153
|
+
if (method === 'edit/keywords_2/volumes/go') { state.launched += 1; return send(true); }
|
|
154
|
+
if (method === 'get/keywords_2/keywords') {
|
|
155
|
+
state.polls += 1;
|
|
156
|
+
const field = body.fields.find((f) => f.startsWith('volume:'));
|
|
157
|
+
return send(state.keywords.filter((k) => k.group_id === body.filters[0].values[0]).map((k, i) => ({ id: k.id, name: k.name, [field]: state.polls >= 2 ? (i + 1) * 100 : null })));
|
|
158
|
+
}
|
|
159
|
+
return fail(`Call to undefined method: ${method}`);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ state, server, base: `http://127.0.0.1:${server.address().port}/` })));
|
|
163
|
+
}
|
|
164
|
+
{
|
|
165
|
+
const { state, server, base } = await fakeTopvisor();
|
|
166
|
+
const client = topvisorClient({ userId: '5', key: 'secret', base });
|
|
167
|
+
const dry = await volumesViaTopvisor(['купить квартиру в сочи', 'дом у моря цена'], { lang: 'ru', region: 225, client, dry: true, groupName: 'x.ru 2026-09-15' });
|
|
168
|
+
ok('сухой прогон: цена получена, ничего не запущено', dry.price === 0.04 && !dry.launched && state.launched === 0 && /dry/.test(dry.note));
|
|
169
|
+
ok('заголовки с ключами на месте', state.calls.every((c) => c.auth === 'bearer secret' && c.uid === '5'));
|
|
170
|
+
const order = state.calls.map((c) => c.method);
|
|
171
|
+
ok('проект создан, потому что его не было', order.includes('add/projects_2/projects') && state.projects.some((p) => p.name === 'OperStack: карта спроса'));
|
|
172
|
+
ok('цена спрошена после импорта и до запуска', order.indexOf('add/keywords_2/keywords/import') < order.indexOf('get/keywords_2/volumes/price') && !order.includes('edit/keywords_2/volumes/go'));
|
|
173
|
+
const priceCall = state.calls.find((c) => c.method === 'get/keywords_2/volumes/price').body;
|
|
174
|
+
is('определитель частоты: регион, Яндекс, тип 1', priceCall.qualifiers, [{ region_key: 225, searcher_key: 0, type: 1 }]);
|
|
175
|
+
is('фильтр по группе, а не по всему проекту', priceCall.filters[0].name, 'group_id');
|
|
176
|
+
const full = await volumesViaTopvisor(['купить квартиру в сочи', 'дом у моря цена'], { lang: 'ru', region: 225, client, groupName: 'x.ru 2026-09-15 b', pollMs: 1 });
|
|
177
|
+
ok('полный прогон: запущено один раз, частоты заполнены', state.launched === 1 && full.launched && full.filled === 2 && full.volumes['купить квартиру в сочи'] === 100 && full.volumes['дом у моря цена'] === 200);
|
|
178
|
+
ok('повторное добавление поисковика не ошибка', full.projectId === 7);
|
|
179
|
+
const en = await volumesViaTopvisor(['buy condo miami'], { lang: 'en', region: 84, client, groupName: 'y.com', pollMs: 1 });
|
|
180
|
+
is('для английского: Google, тип 3, регион 84', state.calls.filter((c) => c.method === 'edit/keywords_2/volumes/go').pop().body.qualifiers, [{ region_key: 84, searcher_key: 1, type: 3 }]);
|
|
181
|
+
ok('поле частоты названо по определителю', state.calls.filter((c) => c.method === 'get/keywords_2/keywords').pop().body.fields.includes('volume:84:1:3'));
|
|
182
|
+
ok('без ключей источник «none», а не ошибка', (await volumesViaTopvisor(['x y'], { client: null })).source === 'none');
|
|
183
|
+
server.close();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- вся карта с подделками и вывод
|
|
187
|
+
{
|
|
188
|
+
const { state, server, base } = await fakeTopvisor();
|
|
189
|
+
const client = topvisorClient({ userId: '5', key: 'secret', base });
|
|
190
|
+
const files = {
|
|
191
|
+
'https://x.ru/': '<html><head><title>Квартиры у моря в Сочи | Юг-Дом</title></head><body><h1>Квартиры у моря в Сочи</h1><nav><a href="/">Главная</a><a href="/doma/">Дома в Сочи</a></nav></body></html>',
|
|
192
|
+
'https://x.ru/robots.txt': 'Sitemap: https://x.ru/sitemap.xml',
|
|
193
|
+
'https://x.ru/sitemap.xml': '<urlset><url><loc>https://x.ru/kvartiry-u-morya-v-sochi/</loc></url><url><loc>https://x.ru/doma/</loc></url></urlset>',
|
|
194
|
+
};
|
|
195
|
+
const fetchJson = async (u) => { const q = decodeURIComponent((u.match(/[?&](?:q|part)=([^&]*)/) || [])[1] || ''); const b = q.replace(/^(как|где|что такое|сколько стоит)\s+/, '').replace(/\s+(цена|отзывы|или)$/, ''); return [q, [`${b} недорого`, `${b} вторичка`]]; };
|
|
196
|
+
const d = await buildDemandMap('https://x.ru/', { lang: 'ru', client, fetchText: async (u) => { if (!files[u]) throw new Error('404'); return files[u]; }, fetchJson, delayMs: 0, pollMs: 1, max: 20, engines: ['yandex'] });
|
|
197
|
+
ok('хозяин и регион названы', d.host === 'x.ru' && d.region === 225 && d.regionName === 'Россия');
|
|
198
|
+
ok('зёрна без бренда', d.seeds.includes('квартиры у моря в сочи') && !d.seeds.some((s) => /юг-дом/.test(s)));
|
|
199
|
+
ok('частоты собраны на все запросы', d.volumes.source === 'Topvisor' && d.totals.withVolume === d.totals.queries && d.totals.queries > 0);
|
|
200
|
+
ok('отсортировано по частоте', d.queries.every((q, i) => i === 0 || (d.queries[i - 1].volume ?? -1) >= (q.volume ?? -1)));
|
|
201
|
+
ok('итоги сходятся', d.totals.covered + d.totals.uncovered === d.totals.queries && d.totals.volumeCovered + d.totals.volumeUncovered === d.totals.volumeTotal);
|
|
202
|
+
ok('есть и покрытые, и непокрытые', d.totals.covered > 0 && d.totals.uncovered > 0);
|
|
203
|
+
ok('в JSON нет undefined', !/undefined/.test(JSON.stringify(d)));
|
|
204
|
+
const html = renderDemandHtml(d);
|
|
205
|
+
const md = renderDemandMarkdown(d);
|
|
206
|
+
ok('в выводе нет null, undefined, NaN', !/null|undefined|NaN/.test(html + md));
|
|
207
|
+
ok('вывод называет долю спроса без страницы', /% спроса ниши/.test(html) && /% спроса ниши/.test(md));
|
|
208
|
+
ok('источник назван и сказано, где проверить бесплатно', /Topvisor/.test(html) && /wordstat\.yandex\.ru/.test(html) && /в баллы отчёта они не входят/.test(html));
|
|
209
|
+
ok('таблица содержит запросы и адреса', /<table>/.test(html) && /\/kvartiry-u-morya-v-sochi\//.test(html));
|
|
210
|
+
const enHtml = renderDemandHtml({ ...d, lang: 'en', regionName: 'Russia' }, { lang: 'en' });
|
|
211
|
+
ok('английский вывод без кириллицы вне запросов', !/[А-Яа-яЁё]/.test(enHtml.replace(/<td>[^<]*<\/td>/g, '')));
|
|
212
|
+
const row = demandSourceRow(d);
|
|
213
|
+
ok('строка источников: платно и не в балл', /Платный/.test(row.access) && /не входит/.test(row.access));
|
|
214
|
+
const csv = demandCsv(d).split('\n');
|
|
215
|
+
ok('csv: шапка плюс строка на запрос', csv.length === d.queries.length + 1 && csv[0] === '"query","volume","has_page","url"');
|
|
216
|
+
const off = await buildDemandMap('https://x.ru/', { lang: 'ru', volumes: false, fetchText: async (u) => files[u] || '', fetchJson, delayMs: 0, max: 5, engines: ['yandex'] });
|
|
217
|
+
ok('без частот доля считается по числу запросов и так и сказано', off.volumes.source === 'none' && /по числу запросов/.test(renderDemandHtml(off)) && /% найденных вопросов/.test(renderDemandHtml(off)));
|
|
218
|
+
ok('запрос без частоты подписан словами, а не нулём', /нет данных/.test(renderDemandHtml(off)) && !/<td class="num">0<\/td>/.test(renderDemandHtml(off)));
|
|
219
|
+
const empty = renderDemandHtml({ host: 'e.ru', lang: 'ru', region: 225, regionName: 'Россия', seeds: [], discovery: { engines: ['yandex'] }, volumes: { source: 'none' }, queries: [], totals: demandTotals([]) });
|
|
220
|
+
ok('пустая карта объяснена словами', /ни одного запроса/.test(empty) && !/NaN/.test(empty));
|
|
221
|
+
is('регионов по умолчанию хватает на оба языка', [REGIONS[225].engine, REGIONS[84].engine], ['yandex', 'google']);
|
|
222
|
+
server.close();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- отчёт: карта печатается приложением, входит в таблицу источников и не трогает баллы
|
|
226
|
+
{
|
|
227
|
+
const sample = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
228
|
+
const before = JSON.stringify(computeScores(sample.checks || []).scores);
|
|
229
|
+
const fixture = { host: 'example-villas.example', lang: 'en', region: 84, regionName: 'United States', seeds: ['villas phuket'], discovery: { calls: 8, failed: 0, engines: ['google'] }, pagesIndexed: 3, volumes: { source: 'Topvisor', engine: 'google', type: 3, price: 1.2, currency: 'RUB', filled: 2, asked: 2, note: '', projectId: 1, groupId: 2 }, queries: [{ q: 'villas phuket for sale', volume: 880, covered: true, url: 'https://www.example-villas.example/villas/' }, { q: 'phuket villa rental', volume: 590, covered: false, url: null }] };
|
|
230
|
+
fixture.totals = demandTotals(fixture.queries);
|
|
231
|
+
const withMap = { ...sample, demand: fixture };
|
|
232
|
+
const html = toHtml(withMap);
|
|
233
|
+
ok('в отчёте появилась страница «11 · Demand»', /11 · Demand/.test(html) && /Demand map/.test(html));
|
|
234
|
+
ok('таблица источников называет Topvisor платным и не входящим в балл', /Topvisor \(volumes for the demand map\)/.test(html) && /counts towards no score/.test(html));
|
|
235
|
+
ok('без карты страницы нет', !/11 · Demand/.test(toHtml(sample)));
|
|
236
|
+
is('баллы с картой и без неё одинаковы', JSON.stringify(computeScores(withMap.checks || []).scores), before);
|
|
237
|
+
ok('проверка баллов проходит с картой', verifyScores(withMap).ok !== false);
|
|
238
|
+
const ruHtml = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru' }, demand: { ...fixture, lang: 'ru', regionName: 'США' } });
|
|
239
|
+
ok('русский отчёт: «11 · Спрос»', /11 · Спрос/.test(ruHtml) && /Карта спроса/.test(ruHtml));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
243
|
+
console.log('\nкарта спроса: зёрна, подсказки, частоты, покрытие и вывод в порядке');
|
package/src/test-prompts.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Задания для ИИ-агента: полнота, язык, запрет на выдумки. */
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
4
5
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
5
6
|
|
|
6
7
|
let bad = 0;
|
|
@@ -79,5 +80,33 @@ ok('и предупреждает делать по одной', /по одно
|
|
|
79
80
|
ok('и обещает, что код знать не нужно', /кода знать не нужно/i.test(head));
|
|
80
81
|
ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
|
|
81
82
|
|
|
83
|
+
// ---- первая правка на бесплатной странице тремя частями.
|
|
84
|
+
// Список id берётся из исходника бесплатной проверки, а не из головы: новая находка без
|
|
85
|
+
// сопоставления уронит этот тест, а не оставит покупателя без задачи молча.
|
|
86
|
+
{
|
|
87
|
+
const src = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|
|
88
|
+
const ids = [...new Set([...src.matchAll(/id: '([a-z0-9-]+)', level: (?!'na')/g)].map((m) => m[1]))];
|
|
89
|
+
ok(`находок бесплатной проверки в исходнике больше десяти (${ids.length})`, ids.length > 10);
|
|
90
|
+
const missing = [];
|
|
91
|
+
for (const id of ids) for (const lang of ['ru', 'en']) {
|
|
92
|
+
const p = firstFixParts({ id, area: 'x', text: 'что-то измерено' }, { lang });
|
|
93
|
+
if (!p || !p.now || p.task.length < 30 || p.verify.length < 20 || /null|undefined/.test(JSON.stringify(p))) missing.push(`${id}/${lang}`);
|
|
94
|
+
else if (lang === 'en' && /[А-Яа-яЁё]/.test(p.task + p.verify + p.rule + Object.values(p.labels).join(''))) missing.push(`${id}/en: кириллица`);
|
|
95
|
+
}
|
|
96
|
+
is('у каждой находки бесплатной проверки есть три части на обоих языках', missing, []);
|
|
97
|
+
is('и в списке экспорта нет лишних id, которых проверка не выдаёт', FIRST_FIX_IDS.filter((id) => !ids.includes(id)), []);
|
|
98
|
+
is('неизвестная находка даёт null, а не чужое указание', firstFixParts({ id: 'что-то-новое', text: 'x' }, { lang: 'ru' }), null);
|
|
99
|
+
is('находка без текста даёт null', firstFixParts({ id: 'llms-missing', text: '' }, { lang: 'ru' }), null);
|
|
100
|
+
is('пустой вход даёт null', firstFixParts(null), null);
|
|
101
|
+
const p = firstFixParts({ id: 'robots-fetchers-blocked', area: 'Пускает ли роботов ИИ', text: 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.' }, { lang: 'ru' });
|
|
102
|
+
is('«сейчас» это сам текст находки', p.now, 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.');
|
|
103
|
+
ok('задача про открытие поисковых роботов', /Открой в robots\.txt/.test(p.task));
|
|
104
|
+
ok('подписи на русском', p.labels.now === 'Сейчас' && p.labels.task === 'Задача' && p.labels.verify === 'Как проверить');
|
|
105
|
+
ok('правило про выдумки на месте', /не выдумывай факты/.test(p.rule));
|
|
106
|
+
const e = firstFixParts({ id: 'llms-missing', text: 'No llms.txt.' }, { lang: 'en' });
|
|
107
|
+
ok('английские подписи', e.labels.now === 'Now' && e.labels.verify === 'How to check');
|
|
108
|
+
ok('незнакомый язык падает в английский', firstFixParts({ id: 'llms-missing', text: 'x' }, { lang: 'de' }).labels.now === 'Now');
|
|
109
|
+
}
|
|
110
|
+
|
|
82
111
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
83
112
|
console.log('\nвсе тесты заданий прошли');
|