@operstack/audit 0.15.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/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, runProspect, summarise, toCsv, toMarkdown } 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
 
@@ -108,7 +108,12 @@ if (cmd === 'collect') {
108
108
  writeFileSync(`${base}.csv`, toCsv(rows));
109
109
  writeFileSync(`${base}.md`, toMarkdown(rows));
110
110
  for (const f of failed) console.error(` skipped ${f.url}: ${f.reason}`);
111
- console.log(`wrote ${base}.csv and ${base}.md: ${rows.length} site(s), weakest first`);
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` : ''));
112
117
  } else if (cmd === 'batch') {
113
118
  const brand = brandFrom(opt, has);
114
119
  const items = readList(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.15.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,7 +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, toCsv, toMarkdown } from './prospect.mjs';
14
+ import { prospectRow, rank, split, toCsv, toMarkdown } from './prospect.mjs';
15
15
 
16
16
  /** Строки списка: адрес и, через запятую, имя клиента для обложки. Пустые строки и # игнорируются. */
17
17
  export function parseList(text) {
@@ -94,7 +94,16 @@ export async function runProspect(items, { pages = 8, lang = 'en', log = () => {
94
94
  if (item.invalid) { failed.push({ ...item, reason: 'not a valid address' }); log(`${label}: пропущен`); continue; }
95
95
  try {
96
96
  log(`${label}: проверяю`);
97
- const audit = await collect(item.url, { pages, lang, rendered: false });
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
+ }
98
107
  rows.push(prospectRow(audit, { name: item.name, lang }));
99
108
  } catch (err) {
100
109
  failed.push({ ...item, reason: err.message });
@@ -104,4 +113,4 @@ export async function runProspect(items, { pages = 8, lang = 'en', log = () => {
104
113
  return { rows, failed };
105
114
  }
106
115
 
107
- export { toCsv, toMarkdown };
116
+ export { rank, split, toCsv, toMarkdown };
package/src/prospect.mjs CHANGED
@@ -194,12 +194,15 @@ export function overallScore(scores = {}) {
194
194
  return Math.round((sum / weight) * 10);
195
195
  }
196
196
 
197
- const SEVERITY = { fail: 0, warn: 1 };
197
+ // Сборщик размечает проверки как ok, warn, bad и na. Слова fail в нём нет, и код,
198
+ // который его искал, пропускал мимо себя всё самое тяжёлое.
199
+ const SEVERITY = { bad: 0, warn: 1 };
200
+ const OPEN = (s) => s === 'bad' || s === 'warn';
198
201
 
199
202
  /** Худшие находки по одному сайту: сначала провалы, потом предупреждения, и только те, что слышит владелец. */
200
203
  export function worstFindings(audit, limit = 3, lang = 'en') {
201
204
  const open = (audit.checks || [])
202
- .filter((c) => c.status === 'fail' || c.status === 'warn')
205
+ .filter((c) => OPEN(c.status))
203
206
  .sort((a, b) => {
204
207
  const s = (SEVERITY[a.status] ?? 2) - (SEVERITY[b.status] ?? 2);
205
208
  if (s) return s;
@@ -218,6 +221,7 @@ export function worstFindings(audit, limit = 3, lang = 'en') {
218
221
 
219
222
  /** Одна строка таблицы: сайт, оценка, находки и фраза для письма. */
220
223
  export function prospectRow(audit, { name = '', lang = 'en' } = {}) {
224
+ const reachable = audit.meta?.reachable !== false;
221
225
  const findings = worstFindings(audit, 3, lang);
222
226
  // Фразу для письма ищем по всем открытым находкам, а не только по трём верхним: иначе в письмо
223
227
  // попадёт сырая техническая строка вроде «API catalogue (RFC 9727): not present», которую
@@ -229,10 +233,11 @@ export function prospectRow(audit, { name = '', lang = 'en' } = {}) {
229
233
  return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
230
234
  })[0] || findings[0] || null;
231
235
  return {
236
+ reachable,
232
237
  host: audit.meta?.host || '',
233
238
  name: name || audit.client?.name || audit.meta?.host || '',
234
239
  score: overallScore(audit.scores),
235
- failing: (audit.checks || []).filter((c) => c.status === 'fail').length,
240
+ failing: (audit.checks || []).filter((c) => c.status === 'bad').length,
236
241
  warning: (audit.checks || []).filter((c) => c.status === 'warn').length,
237
242
  findings,
238
243
  say: lead ? lead.say : (lang === 'ru'
@@ -241,9 +246,13 @@ export function prospectRow(audit, { name = '', lang = 'en' } = {}) {
241
246
  };
242
247
  }
243
248
 
244
- /** Сортировка: сначала те, у кого хуже. Сайт без оценки уходит в конец, а не наверх. */
249
+ /** Сортировка: сначала те, у кого хуже. Сайт без оценки уходит в конец, а не наверх.
250
+ * Повторы по адресу схлопываются: список клиентов человек собирает руками, и один и тот же
251
+ * сайт в нём попадается. */
245
252
  export function rank(rows) {
246
- return [...rows].sort((a, b) => {
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) => {
247
256
  if (a.score === null) return 1;
248
257
  if (b.score === null) return -1;
249
258
  if (a.score !== b.score) return a.score - b.score;
@@ -256,20 +265,35 @@ const csvCell = (v) => {
256
265
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
257
266
  };
258
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
+
259
273
  export function toCsv(rows) {
260
- const head = ['site', 'name', 'score', 'failing', 'warnings', 'what to say'];
261
- return [head, ...rank(rows).map((r) => [r.host, r.name, r.score ?? '', r.failing, r.warning, r.say])]
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])]
262
276
  .map((line) => line.map(csvCell).join(','))
263
277
  .join('\n');
264
278
  }
265
279
 
266
280
  export function toMarkdown(rows, { title = 'Prospects, worst first' } = {}) {
267
- const ranked = rank(rows);
268
- const out = [`# ${title}`, '', `${ranked.length} site(s), the weakest at the top.`, '',
269
- '| # | Site | Score | Fails | What to say in the first line |', '|---:|---|---:|---:|---|'];
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 |', '|---:|---|---:|---:|---|'];
270
289
  ranked.forEach((r, i) => {
271
290
  out.push(`| ${i + 1} | ${r.host} | ${r.score ?? 'n/a'} | ${r.failing} | ${r.say.replace(/\|/g, '/')} |`);
272
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
+ }
273
297
  out.push('', '## What each one has open', '');
274
298
  for (const r of ranked) {
275
299
  out.push(`### ${r.name} (${r.host})`);
@@ -100,7 +100,7 @@ const mk = (id, status, scores) => ({
100
100
  meta: { host: `${id}.example` }, client: { name: id }, scores,
101
101
  checks: [{ id, group: 'x', label: 'L', status, value: 'v' }],
102
102
  });
103
- const weak = prospectRow(mk('ai-search-access', 'fail', { a: 2 }));
103
+ const weak = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
104
104
  ok('перекрытый доступ роботов ИИ попадает в письмо', /ChatGPT/.test(weak.say));
105
105
  ok('провал посчитан', weak.failing === 1 && weak.warning === 0);
106
106
 
@@ -124,9 +124,26 @@ ok('в CSV есть заголовок и строка', csv.split('\n').length
124
124
  ok('запятая внутри фразы не ломает CSV', toCsv([{ ...weak, say: 'a, b' }]).includes('"a, b"'));
125
125
  ok('в таблице сказано, что строки измерены, а не придуманы', /measured check, not an opinion/.test(toMarkdown([weak])));
126
126
 
127
- const russian = prospectRow(mk('ai-search-access', 'fail', { a: 2 }), { lang: 'ru' });
127
+ const russian = prospectRow(mk('ai-search-access', 'bad', { a: 2 }), { lang: 'ru' });
128
128
  ok('русская фраза приходит по-русски', /Perplexity не могут/.test(russian.say));
129
129
 
130
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
+
131
147
  console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
132
148
  process.exit(failed ? 1 : 0);
149
+