@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 +7 -2
- package/package.json +1 -1
- package/src/batch.mjs +12 -3
- package/src/prospect.mjs +34 -10
- package/src/test-agency.mjs +19 -2
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
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
|
-
|
|
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', '
|
|
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
|
|
268
|
-
const
|
|
269
|
-
|
|
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})`);
|
package/src/test-agency.mjs
CHANGED
|
@@ -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', '
|
|
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', '
|
|
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
|
+
|