@operstack/audit 0.7.1 → 0.7.2
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 +1 -1
- package/bin/audit.mjs +5 -3
- package/package.json +1 -1
- package/src/collect.mjs +27 -14
- package/src/foundation.mjs +29 -8
- package/src/test-foundation.mjs +15 -1
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ npx @operstack/audit foundation-scope audit.json --lang en --new-pages 3
|
|
|
79
79
|
|
|
80
80
|
Each sampled page is read for five things: does it open with an answer carrying a figure, are the figures sourced, is it broken into sections, does it carry a table worth quoting, and is there enough of it at all. A page needing one edit is one share of work, two or three defects is two, four or more (or an empty page) is three, and a page that does not exist yet is three. Shares times the unit price, lifted to the package minimum.
|
|
81
81
|
|
|
82
|
-
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.
|
|
82
|
+
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.
|
|
83
83
|
|
|
84
84
|
## Example
|
|
85
85
|
|
package/bin/audit.mjs
CHANGED
|
@@ -45,8 +45,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
|
45
45
|
' what the Fix package closes on this site: plan, client letter, executor checklist',
|
|
46
46
|
' operstack-audit fix-report <audit-fix-plan.json> <after.json> [--out report.md]',
|
|
47
47
|
' what was closed, what was not, and how much to refund',
|
|
48
|
-
' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--
|
|
49
|
-
' the text work the Foundation package would do, page by page
|
|
48
|
+
' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--price] [--out base]',
|
|
49
|
+
' the text work the Foundation package would do, page by page; --price also states the money',
|
|
50
50
|
].join('\n'));
|
|
51
51
|
process.exit(cmd ? 0 : 2);
|
|
52
52
|
}
|
|
@@ -114,6 +114,7 @@ if (cmd === 'collect') {
|
|
|
114
114
|
lang,
|
|
115
115
|
newPages: Number(opt('--new-pages', 0)),
|
|
116
116
|
hasSearchConsole: has("--gsc"),
|
|
117
|
+
withPrice: has('--price'),
|
|
117
118
|
unit: opt('--unit') ? Number(opt('--unit')) : undefined,
|
|
118
119
|
minimum: opt('--minimum') ? Number(opt('--minimum')) : undefined,
|
|
119
120
|
currency: opt('--currency'),
|
|
@@ -123,6 +124,7 @@ if (cmd === 'collect') {
|
|
|
123
124
|
writeFileSync(resolve(`${base}-foundation-letter.md`), renderFoundationScope(scope));
|
|
124
125
|
writeFileSync(resolve(`${base}-foundation-checklist.md`), renderFoundationChecklist(scope));
|
|
125
126
|
console.log(`${scope.pages.length} page(s) need text work of ${scope.sampledPages} sampled, ${scope.shares} share(s)`);
|
|
126
|
-
console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
|
|
127
|
+
if (scope.withPrice) console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
|
|
128
|
+
else console.log('no price in the letter: add --price once the list is agreed and you have decided what to charge');
|
|
127
129
|
console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
|
|
128
130
|
} else { console.error(`unknown command ${cmd}`); process.exit(2); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
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/collect.mjs
CHANGED
|
@@ -59,9 +59,22 @@ function analysePage(url, html) {
|
|
|
59
59
|
walk(j);
|
|
60
60
|
} catch { schemaTypes.push('(invalid JSON-LD)'); }
|
|
61
61
|
}
|
|
62
|
+
// Служебная обвязка вырезается, кроме <header>, в котором стоит H1: это шапка статьи, а не сайта.
|
|
63
|
+
const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
|
|
64
|
+
.replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
65
|
+
.replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
|
|
66
|
+
const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
|
67
|
+
const countWords = (x) => (x.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
62
68
|
const body = root.querySelector('body');
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
// Парсер иногда не находит body в большом или неаккуратном HTML и молча возвращает пустой текст,
|
|
70
|
+
// а пустой текст это приговор «страница пустая». Проверено на css-tricks.com: 178 КБ, body не
|
|
71
|
+
// найден, слов ноль при живых 978. Поэтому берём тот текст, которого больше: пустоту показывать
|
|
72
|
+
// клиенту нельзя, это ложное обвинение, за которое он ещё и заплатит в смете.
|
|
73
|
+
const parsedText = (body?.text ?? '').replace(/\s+/g, ' ').trim();
|
|
74
|
+
const text = countWords(parsedText) >= countWords(plain(bodyHtml)) ? parsedText : plain((html.match(/<body[\s\S]*<\/body>/i) || [html])[0].replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' '));
|
|
75
|
+
// Слова считаем по содержимому без меню и подвала: иначе страница из одного абзаца в большом
|
|
76
|
+
// шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
|
|
77
|
+
const words = countWords(plain(bodyHtml)) || countWords(text);
|
|
65
78
|
const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
|
|
66
79
|
const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
|
|
67
80
|
const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
|
|
@@ -108,11 +121,6 @@ function analysePage(url, html) {
|
|
|
108
121
|
author: /"author"\s*:/.test(ld),
|
|
109
122
|
};
|
|
110
123
|
// Answer-first family, the same measures the free AI visibility check uses, so the two agree.
|
|
111
|
-
// Site chrome is stripped, except a <header> that carries the H1: that is the article head, not chrome.
|
|
112
|
-
const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
|
|
113
|
-
.replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
114
|
-
.replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
|
|
115
|
-
const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
|
116
124
|
const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
|
|
117
125
|
const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
|
|
118
126
|
const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
@@ -125,13 +133,18 @@ function analysePage(url, html) {
|
|
|
125
133
|
const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
|
|
126
134
|
// Абзацы, в которых вообще есть цифра. Без этого счётчика «цифры без источника» вменяется
|
|
127
135
|
// странице, где цифр нет ни одной, например политике конфиденциальности.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
// Абзацы берём разбором дерева, а не регуляркой: на странице с незакрытым <p> регулярка
|
|
137
|
+
// склеивает переключатель валют, заголовок и текст в один «абзац с цифрой». Порог в двенадцать
|
|
138
|
+
// слов отсекает служебные обрывки: цена в карточке это не утверждение, которому нужен источник.
|
|
139
|
+
const PROSE_WORDS = 12;
|
|
140
|
+
const paras = (body ? body.querySelectorAll('p') : []).map((el) => ({
|
|
141
|
+
text: (el.text || '').replace(/\s+/g, ' ').trim(),
|
|
142
|
+
hrefs: el.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || ''),
|
|
143
|
+
})).filter((x) => countWords(x.text) >= PROSE_WORDS && /\d/.test(x.text));
|
|
144
|
+
const figureParagraphs = paras.length;
|
|
145
|
+
const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
|
|
146
|
+
try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
|
|
147
|
+
})).length;
|
|
135
148
|
return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
|
|
136
149
|
}
|
|
137
150
|
|
package/src/foundation.mjs
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* Чего этот расчёт не знает: какие страницы приносят показы. Это видно только в Search Console,
|
|
14
14
|
* доступ к которой даёт клиент, и это бесплатно. Без доступа мы считаем по выборке и говорим
|
|
15
15
|
* об этом прямо, а не делаем вид, что выбрали важные страницы.
|
|
16
|
+
*
|
|
17
|
+
* Деньги в письмо по умолчанию не попадают. Объём работы машина считает честно, а цену называет
|
|
18
|
+
* человек: короткая страница бывает короткой намеренно, и счёт, выставленный автоматом, окажется
|
|
19
|
+
* счётом за работу, которой не нужно было делать. Цена включается флагом, осознанно.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -84,13 +88,17 @@ export function buildFoundationScope(audit, opts = {}) {
|
|
|
84
88
|
const unit = Number(opts.unit ?? (lang === 'en' ? 50 : 4200));
|
|
85
89
|
const minimum = Number(opts.minimum ?? (lang === 'en' ? 500 : 42000));
|
|
86
90
|
const newPages = Math.max(0, Number(opts.newPages ?? 0));
|
|
91
|
+
const withPrice = opts.withPrice === true;
|
|
87
92
|
|
|
88
93
|
const sampled = (audit.sample || []).filter((p) => p.title !== undefined && typeof p.words === 'number');
|
|
89
94
|
const utility = sampled.filter((p) => isUtilityPage(p.url));
|
|
90
95
|
const content = sampled.filter((p) => !isUtilityPage(p.url));
|
|
91
96
|
const pages = [];
|
|
92
97
|
for (const p of content) {
|
|
93
|
-
|
|
98
|
+
let issues = PAGE_ISSUES.filter((i) => i.test(p));
|
|
99
|
+
// Странице, которую пишем заново, незачем отдельной строкой советовать переписать первый
|
|
100
|
+
// абзац: это та же работа, названная дважды, и в списке она выглядит как две.
|
|
101
|
+
if (issues.some((i) => i.heavy)) issues = issues.filter((i) => i.heavy);
|
|
94
102
|
if (!issues.length) continue;
|
|
95
103
|
const tier = tierOf(issues);
|
|
96
104
|
pages.push({
|
|
@@ -112,7 +120,7 @@ export function buildFoundationScope(audit, opts = {}) {
|
|
|
112
120
|
const price = Math.max(minimum, raw);
|
|
113
121
|
|
|
114
122
|
return {
|
|
115
|
-
lang, currency, unit, minimum, newPages,
|
|
123
|
+
lang, currency, unit, minimum, newPages, withPrice,
|
|
116
124
|
host: audit.meta?.host,
|
|
117
125
|
checkedAt: audit.meta?.collectedAt,
|
|
118
126
|
sampledPages: sampled.length,
|
|
@@ -134,6 +142,9 @@ const T = {
|
|
|
134
142
|
tier: { rewrite: 'написать заново', rework: 'переработать', touch: 'одна правка' },
|
|
135
143
|
words: (n) => `${n} ${plural(n, 'слово', 'слова', 'слов')}`,
|
|
136
144
|
newPages: (n, s) => `## Новые страницы: ${n}\n\nСтраниц, которых у вас нет, а рынок их спрашивает: ${n}. Каждая считается как страница с нуля, это ${s} ${plural(s, 'доля', 'доли', 'долей')}.`,
|
|
145
|
+
workHead: '## Объём работы',
|
|
146
|
+
workBody: (s) => `Всего ${s} ${plural(s, 'доля', 'доли', 'долей')} работы. Доля это работа над одной страницей: одна правка доля, две или три две доли, четыре и больше или пустая страница три. Цену мы назовём отдельно, когда вы согласуете сам список.`,
|
|
147
|
+
thinWarn: 'Среди них есть страницы короче трёхсот слов. Если такие страницы у вас задуманы короткими, это анонсы, новости или ссылки на чужой материал, скажите, и мы уберём их из списка: писать их заново незачем.',
|
|
137
148
|
priceHead: '## Цена',
|
|
138
149
|
priceBody: (s, u, c, raw) => `Доля работы над одной страницей стоит ${u} ${c}. Долей набралось ${s}, это ${raw} ${c}.`,
|
|
139
150
|
atMin: (min, c) => `Минимум пакета ${min} ${c}, поэтому в счёте будет ${min} ${c}: меньше этой суммы браться нет смысла ни нам, ни вам.`,
|
|
@@ -157,6 +168,9 @@ const T = {
|
|
|
157
168
|
tier: { rewrite: 'write from scratch', rework: 'rework', touch: 'one edit' },
|
|
158
169
|
words: (n) => `${n} word${n === 1 ? '' : 's'}`,
|
|
159
170
|
newPages: (n, s) => `## New pages: ${n}\n\nPages you do not have and the market asks for: ${n}. Each counts as a page written from scratch, ${s} share${s === 1 ? '' : 's'}.`,
|
|
171
|
+
workHead: '## Scope of work',
|
|
172
|
+
workBody: (s) => `${s} share${s === 1 ? '' : 's'} of work in total. A share is the work on one page: one edit is a share, two or three defects are two, four or more (or an empty page) three. We name the price separately, once you have agreed the list itself.`,
|
|
173
|
+
thinWarn: 'Some of these are under 300 words. If those pages are meant to be short, link posts, news items, announcements, say so and we will drop them from the list: there is nothing to rewrite.',
|
|
160
174
|
priceHead: '## Price',
|
|
161
175
|
priceBody: (s, u, c, raw) => `One share of work on a page costs ${u} ${c}. The scope came to ${s} shares, which is ${raw} ${c}.`,
|
|
162
176
|
atMin: (min, c) => `The package minimum is ${min} ${c}, so the invoice will say ${min} ${c}: below that it is not worth either side's time.`,
|
|
@@ -194,11 +208,18 @@ export function renderFoundationScope(scope) {
|
|
|
194
208
|
}
|
|
195
209
|
if (scope.newPages) { L.push(t.newPages(scope.newPages, scope.newShares)); L.push(''); }
|
|
196
210
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
if (scope.withPrice) {
|
|
212
|
+
L.push(t.priceHead);
|
|
213
|
+
L.push('');
|
|
214
|
+
L.push(t.priceBody(scope.shares, scope.unit, scope.currency, scope.raw));
|
|
215
|
+
if (scope.atMinimum) L.push(t.atMin(scope.minimum, scope.currency));
|
|
216
|
+
L.push(t.total(scope.price, scope.currency));
|
|
217
|
+
} else {
|
|
218
|
+
L.push(t.workHead);
|
|
219
|
+
L.push('');
|
|
220
|
+
L.push(t.workBody(scope.shares));
|
|
221
|
+
}
|
|
222
|
+
if (scope.pages.some((p) => p.issues.some((i) => i.id === 'thin'))) { L.push(''); L.push(t.thinWarn); }
|
|
202
223
|
L.push('');
|
|
203
224
|
L.push(t.needHead);
|
|
204
225
|
L.push('');
|
|
@@ -216,7 +237,7 @@ export function renderFoundationScope(scope) {
|
|
|
216
237
|
export function renderFoundationChecklist(scope) {
|
|
217
238
|
const t = dict(scope.lang);
|
|
218
239
|
const L = [`# ${scope.lang === 'en' ? 'Foundation checklist' : 'Чек-лист Foundation'}: ${scope.host}`, '',
|
|
219
|
-
`${scope.lang === 'en' ? 'Shares' : 'Долей'}: ${scope.shares} (${scope.pageShares} + ${scope.newShares} ${scope.lang === 'en' ? 'new' : 'новых'}). ${scope.lang === 'en' ? 'Price' : 'Цена'}: ${scope.price} ${scope.currency}
|
|
240
|
+
`${scope.lang === 'en' ? 'Shares' : 'Долей'}: ${scope.shares} (${scope.pageShares} + ${scope.newShares} ${scope.lang === 'en' ? 'new' : 'новых'}).${scope.withPrice ? ` ${scope.lang === 'en' ? 'Price' : 'Цена'}: ${scope.price} ${scope.currency}.` : ''}`, ''];
|
|
220
241
|
for (const p of scope.pages) {
|
|
221
242
|
L.push(`- [ ] ${p.title} (${t.tier[p.tier]}, ${p.shares})`);
|
|
222
243
|
L.push(` ${p.url}`);
|
package/src/test-foundation.mjs
CHANGED
|
@@ -17,6 +17,7 @@ is('цена без работы ноль долей', clean.shares, 0);
|
|
|
17
17
|
|
|
18
18
|
const thin = buildFoundationScope(audit([page({ words: 120 })]));
|
|
19
19
|
is('пустая страница это три доли', thin.pages[0].shares, 3);
|
|
20
|
+
is('и одна работа, а не список повторов', buildFoundationScope(audit([page({ words: 90, answerFirst: false, h2Count: 0, tables: 0 })])).pages[0].issues.map((i) => i.id), ['thin']);
|
|
20
21
|
is('и помечена как «написать заново»', thin.pages[0].tier, 'rewrite');
|
|
21
22
|
|
|
22
23
|
const one = buildFoundationScope(audit([page({ answerFirst: false })]));
|
|
@@ -66,7 +67,20 @@ is('цена доли по умолчанию', en.unit, 50);
|
|
|
66
67
|
is('минимум пакета', en.minimum, 500);
|
|
67
68
|
const letterEn = renderFoundationScope({ ...en, host: 'x.com', pages: en.pages.map((p) => ({ ...p, title: 'A page' })) });
|
|
68
69
|
ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
|
|
69
|
-
|
|
70
|
+
|
|
71
|
+
// Деньги в письме только по флагу: цену называет человек, а не машина
|
|
72
|
+
ok('по умолчанию цены в письме нет', !/USD|RUB/.test(letterEn));
|
|
73
|
+
ok('вместо неё объём работы', letterEn.includes('share') && letterEn.includes('name the price separately'));
|
|
74
|
+
const priced = buildFoundationScope(audit([page({ url: 'https://x.ru/a/', words: 100 })]), { lang: 'en', withPrice: true });
|
|
75
|
+
ok('с флагом цена появляется', renderFoundationScope(priced).includes('500 USD'));
|
|
76
|
+
ok('и в чек-листе тоже', renderFoundationChecklist(priced).includes('500 USD'));
|
|
77
|
+
ok('без флага чек-лист без денег', !/USD|RUB/.test(renderFoundationChecklist(en)));
|
|
78
|
+
is('доли считаются в обоих случаях', [en.shares > 0, priced.shares > 0], [true, true]);
|
|
79
|
+
|
|
80
|
+
// Короткие страницы бывают короткими намеренно, поэтому их просим подтвердить
|
|
81
|
+
const thinLetter = renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/n/', words: 90 })])));
|
|
82
|
+
ok('письмо просит подтвердить короткие страницы', thinLetter.includes('задуманы короткими'));
|
|
83
|
+
ok('без коротких страниц такой оговорки нет', !renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/a/', answerFirst: false })]))).includes('задуманы короткими'));
|
|
70
84
|
|
|
71
85
|
// Чек-лист
|
|
72
86
|
const list = renderFoundationChecklist(many);
|