@operstack/audit 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -166,3 +166,20 @@ report lands in `reports/`, and a site that fails does not stop the rest.
166
166
  The key goes in `--licence`, in `OPERSTACK_LICENCE`, or in a `.operstack-licence` file beside the
167
167
  work. This package is MIT and its source is public, so the key states the terms of commercial use
168
168
  and records who bought them; it is not a technical lock. Plan: https://oper-stack.com/products/agency/
169
+
170
+ ## Who to write to first
171
+
172
+ `prospect` answers the question a batch of reports does not: of twenty sites, which one has the
173
+ worst problem and what do you say in the first line of the email.
174
+
175
+ ```
176
+ operstack-audit prospect clients.txt --out prospects
177
+ ```
178
+
179
+ Out comes `prospects.csv` and `prospects.md`: one row per site, weakest first, with the two or
180
+ three open findings and one sentence written for a business owner rather than for an engineer.
181
+ Every sentence is a translation of a check that measured something; where there is no translation,
182
+ the raw finding is printed instead of an invention.
183
+
184
+ It runs the same checks as the report but skips the writing and the layout, so twenty sites take
185
+ minutes. Free, no licence needed.
package/bin/audit.mjs CHANGED
@@ -31,7 +31,7 @@ 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
33
  import { resolveBranding } from '../src/agency.mjs';
34
- import { readList, runBatch, summarise } from '../src/batch.mjs';
34
+ import { rank, readList, runBatch, runProspect, split, summarise, toCsv, toMarkdown } from '../src/batch.mjs';
35
35
  import { writeFileSync, readFileSync } from 'node:fs';
36
36
  import { resolve } from 'node:path';
37
37
 
@@ -61,6 +61,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
61
61
  ' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--price] [--out base]',
62
62
  ' the text work the Foundation package would do, page by page; --price also states the money',
63
63
  '',
64
+ ' operstack-audit prospect <sites.txt> [--out prospects] [--pages 8] [--lang en|ru]',
65
+ ' who to write to first: one table, weakest site at the top, with a line to open the email',
64
66
  ' operstack-audit batch <sites.txt> [--out reports] [--pages 12] [--lang en|ru] [--pdf] [brand flags]',
65
67
  ' one report per line of the list; a line is "url" or "url, Client Name"',
66
68
  '',
@@ -95,6 +97,23 @@ if (cmd === 'collect') {
95
97
  writeFileSync(out, JSON.stringify(audit, null, 2));
96
98
  console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
97
99
  console.log(`next: fill the narrative fields (operstack-audit check ${out} lists them), then render.`);
100
+ } else if (cmd === 'prospect') {
101
+ const items = readList(target);
102
+ if (!items.length) { console.error('the list is empty'); process.exit(2); }
103
+ const lang = opt('--lang', 'en');
104
+ console.error(`${items.length} site(s) to check`);
105
+ const { rows, failed } = await runProspect(items, { pages: Number(opt('--pages', 8)), lang, log: (m) => console.error(` ${m}`) });
106
+ if (!rows.length) { console.error('nothing could be checked'); process.exit(1); }
107
+ const base = resolve(opt('--out', 'prospects'));
108
+ writeFileSync(`${base}.csv`, toCsv(rows));
109
+ writeFileSync(`${base}.md`, toMarkdown(rows));
110
+ for (const f of failed) console.error(` skipped ${f.url}: ${f.reason}`);
111
+ // Считаем по тем же правилам, по которым печатается таблица: иначе терминал скажет одно,
112
+ // а файл покажет другое, и первым, кто это заметит, будет покупатель.
113
+ const { live, dead } = split(rows);
114
+ const shown = rank(live).length;
115
+ console.log(`wrote ${base}.csv and ${base}.md: ${shown} site(s) to write to, weakest first`
116
+ + (dead.length ? `, ${dead.length} did not answer` : ''));
98
117
  } else if (cmd === 'batch') {
99
118
  const brand = brandFrom(opt, has);
100
119
  const items = readList(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/batch.mjs CHANGED
@@ -11,6 +11,7 @@ import { resolve, join } from 'node:path';
11
11
  import { collect } from './collect.mjs';
12
12
  import { render } from './render.mjs';
13
13
  import { draftNarrative as draft } from './narrative.mjs';
14
+ import { prospectRow, rank, split, toCsv, toMarkdown } from './prospect.mjs';
14
15
 
15
16
  /** Строки списка: адрес и, через запятую, имя клиента для обложки. Пустые строки и # игнорируются. */
16
17
  export function parseList(text) {
@@ -80,3 +81,36 @@ export function summarise(results) {
80
81
  export function readList(path) {
81
82
  return parseList(readFileSync(resolve(path), 'utf8'));
82
83
  }
84
+
85
+ /**
86
+ * Разведка: те же проверки, но без отчётов. Нужен ответ «кому писать первым», а не двадцать PDF,
87
+ * поэтому здесь не собирается ни черновик, ни вёрстка: только замер и таблица.
88
+ */
89
+ export async function runProspect(items, { pages = 8, lang = 'en', log = () => {} } = {}) {
90
+ const rows = [];
91
+ const failed = [];
92
+ for (const [i, item] of items.entries()) {
93
+ const label = `${i + 1}/${items.length} ${item.url}`;
94
+ if (item.invalid) { failed.push({ ...item, reason: 'not a valid address' }); log(`${label}: пропущен`); continue; }
95
+ try {
96
+ log(`${label}: проверяю`);
97
+ let audit = await collect(item.url, { pages, lang, rendered: false });
98
+ // Сеть подводит чаще, чем сайты падают. Один повтор через пару секунд убирает почти все
99
+ // ложные «не отвечает»: без него один и тот же клиент в понедельник в списке, а во вторник
100
+ // в мёртвых, и человек перестаёт верить таблице.
101
+ if (audit.meta?.reachable === false) {
102
+ log(`${label}: не ответил, пробую ещё раз`);
103
+ await new Promise((r) => setTimeout(r, 2500));
104
+ const again = await collect(item.url, { pages, lang, rendered: false });
105
+ if (again.meta?.reachable !== false) audit = again;
106
+ }
107
+ rows.push(prospectRow(audit, { name: item.name, lang }));
108
+ } catch (err) {
109
+ failed.push({ ...item, reason: err.message });
110
+ log(`${label}: не вышло, ${err.message}`);
111
+ }
112
+ }
113
+ return { rows, failed };
114
+ }
115
+
116
+ export { rank, split, toCsv, toMarkdown };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Кому писать первым и что сказать.
3
+ *
4
+ * Пакетный прогон отдаёт двадцать отчётов по десять страниц каждый, и дальше человек три часа
5
+ * читает их подряд, чтобы понять, у кого дыра, а у кого мелочь. Здесь эти три часа сводятся к
6
+ * одной таблице: сайты по возрастанию оценки, две-три худшие находки и одна фраза, которую можно
7
+ * поставить в письмо.
8
+ *
9
+ * Фраза не сочиняется. Она берётся из проверки, которая уже что-то измерила, и переводится на
10
+ * язык владельца бизнеса. Там, где перевода нет, печатается сама находка: пусть сухо, но правда.
11
+ */
12
+
13
+ /**
14
+ * Что сказать владельцу про каждую находку. Здесь только те проверки, по которым владельцу
15
+ * действительно есть что услышать: остальные важны исполнителю, а не в первом письме.
16
+ *
17
+ * Правило для каждой строки: это последствие для его бизнеса, а не название проверки.
18
+ */
19
+ export const OWNER_LINES = {
20
+ 'robots': {
21
+ en: 'Your site tells robots to skip part of it and never shows them the sitemap.',
22
+ ru: 'Служебный файл сайта запрещает роботам часть страниц и не показывает им карту сайта.',
23
+ },
24
+ 'sitemap': {
25
+ en: 'There is no working sitemap, so a search engine simply never finds some of your pages.',
26
+ ru: 'У сайта нет рабочей карты, поэтому часть страниц поисковик просто не находит.',
27
+ },
28
+ 'sitemap-health': {
29
+ en: 'Your sitemap points at pages that have moved or no longer exist.',
30
+ ru: 'Карта сайта ведёт на страницы, которых уже нет или которые переехали.',
31
+ },
32
+ 'ai-search-access': {
33
+ en: 'ChatGPT and Perplexity cannot read your site: one line in a config file shuts them out.',
34
+ ru: 'ChatGPT и Perplexity не могут прочитать ваш сайт: доступ закрыт в одной строке служебного файла.',
35
+ },
36
+ 'llms': {
37
+ en: 'An assistant asked about you has no way to know which of your pages to read, so it reads whichever it hits first.',
38
+ ru: 'Ассистенту, которого спросили про вас, нечем понять, какие ваши страницы читать, и он берёт первую попавшуюся.',
39
+ },
40
+ 'llms-health': {
41
+ en: 'You do have the file assistants look for, but its links lead nowhere.',
42
+ ru: 'Файл для ассистентов у вас есть, но ссылки в нём ведут в никуда.',
43
+ },
44
+ 'agent-card': {
45
+ en: 'There is nothing on the site telling an AI agent who you are and how to reach you.',
46
+ ru: 'На сайте нет карточки, по которой программа-агент понимает, кто вы и как с вами связаться.',
47
+ },
48
+ 'agent-markdown': {
49
+ en: 'Assistants have to parse your layout to read you, because no clean text copy of the pages exists.',
50
+ ru: 'Ассистенту приходится разбирать вёрстку, потому что чистой текстовой копии страниц нет.',
51
+ },
52
+ 'org-schema': {
53
+ en: 'Your markup does not say who you are: no address, no profiles to check you against.',
54
+ ru: 'В разметке не указано, кто вы: ни адреса, ни профилей, по которым вас можно сверить.',
55
+ },
56
+ 'schema': {
57
+ en: 'Nothing in your markup tells a search engine what this organisation is or what it does.',
58
+ ru: 'Поисковик не понимает по разметке, что это за организация и чем она занимается.',
59
+ },
60
+ 'faq-schema': {
61
+ en: 'Your questions and answers are not marked up, so they never appear as an answer in search.',
62
+ ru: 'Вопросы и ответы не размечены, поэтому в выдачу как ответ они не попадают.',
63
+ },
64
+ 'title': {
65
+ en: 'The title of your home page does not say what you do, and that is the first thing both a person and a machine read.',
66
+ ru: 'Заголовок главной страницы не говорит, чем вы занимаетесь, а это первое, что видят и человек, и машина.',
67
+ },
68
+ 'description': {
69
+ en: 'The description shown in search results is not written, so the engine assembles one from scraps of your text.',
70
+ ru: 'Описание страницы в выдаче не написано, поэтому поисковик собирает его сам из обрывков текста.',
71
+ },
72
+ 'h1': {
73
+ en: 'The home page has several top-level headings at once, so it is unclear which one is the subject.',
74
+ ru: 'На главной несколько главных заголовков сразу, и непонятно, какой из них про что.',
75
+ },
76
+ 'dup-titles': {
77
+ en: 'Several of your pages carry the same title, so a search engine cannot tell them apart.',
78
+ ru: 'У нескольких страниц одинаковые заголовки, и поисковик не отличает их друг от друга.',
79
+ },
80
+ 'alt': {
81
+ en: 'Your images carry no captions, so neither image search nor a blind visitor can make sense of them.',
82
+ ru: 'У картинок нет подписей, поэтому ни поиск по картинкам, ни незрячий посетитель их не понимают.',
83
+ },
84
+ 'viewport': {
85
+ en: 'On a phone your page cannot be pinched to zoom: the code forbids it.',
86
+ ru: 'С телефона страницу нельзя увеличить пальцами, масштабирование запрещено в коде.',
87
+ },
88
+ 'answer-first': {
89
+ en: 'Not one page opens with a short direct answer, and that opening is exactly what gets quoted.',
90
+ ru: 'Ни на одной странице нет короткого прямого ответа в первом абзаце, а цитируют именно его.',
91
+ },
92
+ 'tables': {
93
+ en: 'Your pages have no tables, and a table is the easiest thing for an assistant to lift and quote.',
94
+ ru: 'На страницах нет таблиц, а таблицу ассистенту проще всего взять и процитировать.',
95
+ },
96
+ 'sections': {
97
+ en: 'Your pages run as long unbroken text, so an assistant cannot pick out the part that answers the question.',
98
+ ru: 'Страницы идут сплошным текстом, и ассистент не может выделить кусок, который отвечает на вопрос.',
99
+ },
100
+ 'sources': {
101
+ en: 'Your pages carry figures but never say where they came from, so nobody can cite them.',
102
+ ru: 'Цифры на страницах есть, но не сказано, откуда они взяты, и сослаться на них нельзя.',
103
+ },
104
+ 'dates': {
105
+ en: 'Your pages do not show when they were updated, so they read as stale even when they are not.',
106
+ ru: 'На страницах не видно, когда их обновляли, и выглядят они устаревшими, даже если это не так.',
107
+ },
108
+ 'thin': {
109
+ en: 'Several pages are too short to answer anything, and a short page is not quoted.',
110
+ ru: 'Несколько страниц слишком короткие, чтобы на чём-то отвечать, а короткую страницу не цитируют.',
111
+ },
112
+ 'trust-entity': {
113
+ en: 'Your structured data has no postal address, so there is nothing to verify the business against.',
114
+ ru: 'В данных о компании нет почтового адреса, и сверить бизнес не с чем.',
115
+ },
116
+ 'trust-profiles': {
117
+ en: 'There is nothing to check you against from the outside: no profile on any other platform was found.',
118
+ ru: 'Вас не на что сверить со стороны: ни одного профиля на других площадках не нашлось.',
119
+ },
120
+ 'trust-contact': {
121
+ en: 'The site does not give a plain way to reach a human, which both buyers and search engines look for.',
122
+ ru: 'На сайте нет понятного способа связаться с человеком, а его ищут и покупатели, и поисковик.',
123
+ },
124
+ 'trust-about': {
125
+ en: 'There is no page saying who is behind the business, and that is one of the first things checked.',
126
+ ru: 'Нет страницы о том, кто стоит за бизнесом, а это проверяют одним из первых.',
127
+ },
128
+ 'trust-policies': {
129
+ en: 'The site has no terms or privacy page, which costs trust with both buyers and platforms.',
130
+ ru: 'На сайте нет условий и политики конфиденциальности, и это стоит доверия и покупателей, и площадок.',
131
+ },
132
+ 'conv-cta': {
133
+ en: 'Most pages hide the call to action far down, so a visitor who is ready to act has to hunt for it.',
134
+ ru: 'На большинстве страниц призыв к действию спрятан далеко вниз, и готовый покупатель его ищет.',
135
+ },
136
+ 'conv-form-depth': {
137
+ en: 'Your enquiry form sits past the middle of the page, and most phone visitors never scroll that far.',
138
+ ru: 'Форма заявки стоит за серединой страницы, а большинство посетителей с телефона так далеко не листают.',
139
+ },
140
+ 'conv-form-fields': {
141
+ en: 'The enquiry form asks for too much at once, and every extra field costs replies.',
142
+ ru: 'Форма заявки спрашивает слишком много сразу, и каждое лишнее поле стоит ответов.',
143
+ },
144
+ 'conv-contact-path': {
145
+ en: 'Getting from a page to a way of contacting you takes more steps than it should.',
146
+ ru: 'Путь от страницы до способа связаться длиннее, чем нужно.',
147
+ },
148
+ 'utility': {
149
+ en: 'Some pages exist that nobody should land on from search, and they dilute the rest.',
150
+ ru: 'Есть страницы, на которые из поиска попадать не должны, и они размывают остальные.',
151
+ },
152
+ };
153
+
154
+ /**
155
+ * Чем открывать письмо. Порядок здесь не по тяжести проверки, а по силе для владельца: закрытый
156
+ * доступ роботов ИИ он поймёт мгновенно, а «есть лишние страницы» не значит для него ничего, хотя
157
+ * формально обе находки одного веса. Первая же строка решает, дочитают письмо или нет.
158
+ */
159
+ const LEAD_ORDER = [
160
+ 'ai-search-access', 'llms', 'llms-health', 'answer-first', 'sources', 'schema', 'org-schema',
161
+ 'agent-card', 'conv-form-depth', 'conv-cta', 'title', 'description', 'tables', 'sections',
162
+ 'trust-profiles', 'trust-entity', 'trust-contact', 'faq-schema', 'sitemap', 'sitemap-health',
163
+ 'robots', 'thin', 'dup-titles', 'alt', 'viewport', 'trust-about', 'trust-policies',
164
+ 'conv-form-fields', 'conv-contact-path', 'agent-markdown', 'h1', 'dates', 'utility',
165
+ ];
166
+
167
+ /** Фраза владельцу на нужном языке. Нет перевода, значит нет фразы, и печатается сама находка. */
168
+ const line = (id, lang = 'en') => (OWNER_LINES[id] || {})[lang === 'ru' ? 'ru' : 'en'] || '';
169
+
170
+ /** Вес области при подсчёте одной общей оценки. Считаем только то, что правда измерено. */
171
+ const WEIGHT = {
172
+ 'SEO, technical': 1,
173
+ 'SEO, content and structure': 1.2,
174
+ 'AEO, answers and snippets': 1.3,
175
+ 'GEO, visibility in AI systems': 1.3,
176
+ 'Off-page and trust': 0.8,
177
+ 'Conversion and UX': 0.8,
178
+ };
179
+
180
+ /**
181
+ * Одна оценка на сайт из шести областей. Неизмеренная область не занижает и не завышает:
182
+ * она просто не участвует, потому что оценивать неизмеренное значит выдумывать.
183
+ */
184
+ export function overallScore(scores = {}) {
185
+ let sum = 0;
186
+ let weight = 0;
187
+ for (const [area, value] of Object.entries(scores)) {
188
+ if (typeof value !== 'number') continue;
189
+ const w = WEIGHT[area] ?? 1;
190
+ sum += value * w;
191
+ weight += w;
192
+ }
193
+ if (!weight) return null;
194
+ return Math.round((sum / weight) * 10);
195
+ }
196
+
197
+ // Сборщик размечает проверки как ok, warn, bad и na. Слова fail в нём нет, и код,
198
+ // который его искал, пропускал мимо себя всё самое тяжёлое.
199
+ const SEVERITY = { bad: 0, warn: 1 };
200
+ const OPEN = (s) => s === 'bad' || s === 'warn';
201
+
202
+ /** Худшие находки по одному сайту: сначала провалы, потом предупреждения, и только те, что слышит владелец. */
203
+ export function worstFindings(audit, limit = 3, lang = 'en') {
204
+ const open = (audit.checks || [])
205
+ .filter((c) => OPEN(c.status))
206
+ .sort((a, b) => {
207
+ const s = (SEVERITY[a.status] ?? 2) - (SEVERITY[b.status] ?? 2);
208
+ if (s) return s;
209
+ // При равной тяжести вперёд идёт то, для чего есть фраза владельцу: она и попадёт в письмо.
210
+ return (line(b.id, lang) ? 1 : 0) - (line(a.id, lang) ? 1 : 0);
211
+ });
212
+ return open.slice(0, limit).map((c) => ({
213
+ id: c.id,
214
+ status: c.status,
215
+ label: c.label,
216
+ value: c.value,
217
+ say: line(c.id, lang) || `${c.label}: ${c.value}`,
218
+ translated: Boolean(line(c.id, lang)),
219
+ }));
220
+ }
221
+
222
+ /** Одна строка таблицы: сайт, оценка, находки и фраза для письма. */
223
+ export function prospectRow(audit, { name = '', lang = 'en' } = {}) {
224
+ const reachable = audit.meta?.reachable !== false;
225
+ const findings = worstFindings(audit, 3, lang);
226
+ // Фразу для письма ищем по всем открытым находкам, а не только по трём верхним: иначе в письмо
227
+ // попадёт сырая техническая строка вроде «API catalogue (RFC 9727): not present», которую
228
+ // владелец бизнеса не поймёт, хотя ниже лежала находка с человеческим объяснением.
229
+ const all = worstFindings(audit, 99, lang).filter((f) => f.translated);
230
+ const lead = [...all].sort((a, b) => {
231
+ const ia = LEAD_ORDER.indexOf(a.id);
232
+ const ib = LEAD_ORDER.indexOf(b.id);
233
+ return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
234
+ })[0] || findings[0] || null;
235
+ return {
236
+ reachable,
237
+ host: audit.meta?.host || '',
238
+ name: name || audit.client?.name || audit.meta?.host || '',
239
+ score: overallScore(audit.scores),
240
+ failing: (audit.checks || []).filter((c) => c.status === 'bad').length,
241
+ warning: (audit.checks || []).filter((c) => c.status === 'warn').length,
242
+ findings,
243
+ say: lead ? lead.say : (lang === 'ru'
244
+ ? 'Ничего заметного не нашлось: этот сайт в порядке, писать не о чем.'
245
+ : 'Nothing worth writing about: this site is in order.'),
246
+ };
247
+ }
248
+
249
+ /** Сортировка: сначала те, у кого хуже. Сайт без оценки уходит в конец, а не наверх.
250
+ * Повторы по адресу схлопываются: список клиентов человек собирает руками, и один и тот же
251
+ * сайт в нём попадается. */
252
+ export function rank(rows) {
253
+ const seen = new Map();
254
+ for (const r of rows) if (!seen.has(r.host)) seen.set(r.host, r);
255
+ return [...seen.values()].sort((a, b) => {
256
+ if (a.score === null) return 1;
257
+ if (b.score === null) return -1;
258
+ if (a.score !== b.score) return a.score - b.score;
259
+ return b.failing - a.failing;
260
+ });
261
+ }
262
+
263
+ const csvCell = (v) => {
264
+ const s = String(v ?? '');
265
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
266
+ };
267
+
268
+ /** Живые и мёртвые: писать можно только живым, остальные идут отдельным списком. */
269
+ export function split(rows) {
270
+ return { live: rows.filter((r) => r.reachable !== false), dead: rows.filter((r) => r.reachable === false) };
271
+ }
272
+
273
+ export function toCsv(rows) {
274
+ const head = ['site', 'name', 'score', 'problems', 'warnings', 'what to say'];
275
+ return [head, ...rank(split(rows).live).map((r) => [r.host, r.name, r.score ?? '', r.failing, r.warning, r.say])]
276
+ .map((line) => line.map(csvCell).join(','))
277
+ .join('\n');
278
+ }
279
+
280
+ export function toMarkdown(rows, { title = 'Prospects, worst first' } = {}) {
281
+ const { live, dead } = split(rows);
282
+ const ranked = rank(live);
283
+ const out = [`# ${title}`, '',
284
+ dead.length
285
+ ? `${ranked.length} site(s) to write to, weakest at the top. ${dead.length} did not answer and are listed at the end.`
286
+ : `${ranked.length} site(s), the weakest at the top.`,
287
+ '',
288
+ '| # | Site | Score | Problems | What to say in the first line |', '|---:|---|---:|---:|---|'];
289
+ ranked.forEach((r, i) => {
290
+ out.push(`| ${i + 1} | ${r.host} | ${r.score ?? 'n/a'} | ${r.failing} | ${r.say.replace(/\|/g, '/')} |`);
291
+ });
292
+ if (dead.length) {
293
+ out.push('', '## Did not answer', '',
294
+ 'These sites did not respond, so there is nothing measured and nobody to write to. Check the address before you use it.', '');
295
+ for (const r of rank(dead)) out.push(`- ${r.host}`);
296
+ }
297
+ out.push('', '## What each one has open', '');
298
+ for (const r of ranked) {
299
+ out.push(`### ${r.name} (${r.host})`);
300
+ if (!r.findings.length) out.push('Nothing open that an owner would act on.');
301
+ for (const f of r.findings) out.push(`- **${f.label}** (${f.status}): ${f.value}`);
302
+ out.push('');
303
+ }
304
+ out.push('Every line above is a measured check, not an opinion. Open the site and confirm before you send anything.');
305
+ return out.join('\n');
306
+ }
@@ -0,0 +1,48 @@
1
+ /** Идентификаторы проверок, снятые с настоящего прогона: тест сверяет с ними фразы владельцу,
2
+ * чтобы переименованная проверка не оставила строку, которая никогда не сработает. */
3
+ export const CHECK_IDS = [
4
+ "agent-card",
5
+ "agent-markdown",
6
+ "ai-search-access",
7
+ "alt",
8
+ "answer-first",
9
+ "api-catalog",
10
+ "canonical",
11
+ "conv-contact-path",
12
+ "conv-cta",
13
+ "conv-form-depth",
14
+ "conv-form-fields",
15
+ "conv-messenger",
16
+ "dates",
17
+ "description",
18
+ "dup-titles",
19
+ "faq-schema",
20
+ "h1",
21
+ "h1-sample",
22
+ "hreflang",
23
+ "https",
24
+ "llms",
25
+ "llms-health",
26
+ "og",
27
+ "org-schema",
28
+ "resources",
29
+ "robots",
30
+ "robots-ai",
31
+ "schema",
32
+ "sections",
33
+ "sitemap",
34
+ "sitemap-health",
35
+ "sources",
36
+ "tables",
37
+ "thin",
38
+ "title",
39
+ "trust-about",
40
+ "trust-contact",
41
+ "trust-entity",
42
+ "trust-policies",
43
+ "trust-profiles",
44
+ "ttfb",
45
+ "utility",
46
+ "viewport",
47
+ "www"
48
+ ];
@@ -82,5 +82,68 @@ const { findKey } = await import('./agency.mjs');
82
82
  ok('ключ читается из файла .operstack-licence', findKey().startsWith('OSK1.'));
83
83
  process.chdir(cwd);
84
84
 
85
+
86
+ // ── Разведка кандидатов ───────────────────────────────────────────────────────
87
+ // Правило: в письмо никогда не попадает строка, которую владелец бизнеса не поймёт, и
88
+ // ни одна фраза не ссылается на проверку, которой в сборщике нет.
89
+ const { OWNER_LINES, overallScore, prospectRow, rank, toCsv, toMarkdown } = await import('./prospect.mjs');
90
+ const { CHECK_IDS } = await import('./test-agency-ids.mjs');
91
+
92
+ const unknown = Object.keys(OWNER_LINES).filter((id) => !CHECK_IDS.includes(id));
93
+ ok('каждая фраза ссылается на существующую проверку', unknown.length === 0);
94
+ ok('у каждой фразы есть оба языка', Object.values(OWNER_LINES).every((v) => v.en && v.ru));
95
+
96
+ ok('неизмеренная область не тянет оценку вниз', overallScore({ a: 10, b: null }) === 100);
97
+ ok('пустые оценки дают не ноль, а «не измеряли»', overallScore({}) === null);
98
+
99
+ const mk = (id, status, scores) => ({
100
+ meta: { host: `${id}.example` }, client: { name: id }, scores,
101
+ checks: [{ id, group: 'x', label: 'L', status, value: 'v' }],
102
+ });
103
+ const weak = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
104
+ ok('перекрытый доступ роботов ИИ попадает в письмо', /ChatGPT/.test(weak.say));
105
+ ok('провал посчитан', weak.failing === 1 && weak.warning === 0);
106
+
107
+ const dull = prospectRow(mk('utility', 'warn', { a: 9 }));
108
+ const sharp = prospectRow({
109
+ meta: { host: 'x.example' }, client: { name: 'x' }, scores: { a: 5 },
110
+ checks: [
111
+ { id: 'utility', group: 'x', label: 'L', status: 'warn', value: 'v' },
112
+ { id: 'ai-search-access', group: 'x', label: 'L', status: 'warn', value: 'v' },
113
+ ],
114
+ });
115
+ ok('при равной тяжести в письмо идёт сильная находка, а не первая', /ChatGPT/.test(sharp.say));
116
+ ok('слабая находка всё равно объяснена по-человечески', !/^utility/.test(dull.say));
117
+
118
+ const ranked = rank([prospectRow(mk('alt', 'warn', { a: 9 })), weak, { ...dull, score: null }]);
119
+ ok('сначала идёт тот, у кого хуже', ranked[0].score < ranked[1].score);
120
+ ok('сайт без оценки уходит в конец, а не наверх', ranked[ranked.length - 1].score === null);
121
+
122
+ const csv = toCsv([weak]);
123
+ ok('в CSV есть заголовок и строка', csv.split('\n').length === 2 && csv.startsWith('site,name,score'));
124
+ ok('запятая внутри фразы не ломает CSV', toCsv([{ ...weak, say: 'a, b' }]).includes('"a, b"'));
125
+ ok('в таблице сказано, что строки измерены, а не придуманы', /measured check, not an opinion/.test(toMarkdown([weak])));
126
+
127
+ const russian = prospectRow(mk('ai-search-access', 'bad', { a: 2 }), { lang: 'ru' });
128
+ ok('русская фраза приходит по-русски', /Perplexity не могут/.test(russian.say));
129
+
130
+
131
+ // Три дыры, найденные прогоном по списку, который специально собран из ломающего.
132
+ const dead = prospectRow({ meta: { host: 'gone.example', reachable: false }, scores: {}, checks: [] });
133
+ ok('мёртвый сайт помечен, а не выдан как кандидат', dead.reachable === false);
134
+ const { split } = await import('./prospect.mjs');
135
+ const parts = split([dead, prospectRow(mk('alt', 'warn', { a: 9 }))]);
136
+ ok('мёртвые не попадают в таблицу кандидатов', parts.live.length === 1 && parts.dead.length === 1);
137
+ ok('и названы отдельным списком', /Did not answer/.test(toMarkdown([dead, prospectRow(mk('alt', 'warn', { a: 9 }))])));
138
+ ok('мёртвого нет в CSV', !toCsv([dead]).includes('gone.example'));
139
+
140
+ const bad = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
141
+ ok('статус bad считается провалом, а не пропускается', bad.failing === 1);
142
+ ok('и тяжёлая находка доходит до письма', /ChatGPT/.test(bad.say));
143
+
144
+ const twice = rank([prospectRow(mk('alt', 'warn', { a: 9 })), prospectRow(mk('alt', 'warn', { a: 9 }))]);
145
+ ok('один сайт в списке дважды даёт одну строку', twice.length === 1);
146
+
85
147
  console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
86
148
  process.exit(failed ? 1 : 0);
149
+