@operstack/audit 0.3.1 → 0.3.3
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/package.json +1 -1
- package/src/collect.mjs +27 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
@@ -68,7 +68,7 @@ function analysePage(url, html) {
|
|
|
68
68
|
// Conversion path, read from the page itself. No analytics is needed to see whether a visitor
|
|
69
69
|
// who wants to talk to this business can find a way to do it, and how far down it is.
|
|
70
70
|
const bodyInner = body?.innerHTML || html;
|
|
71
|
-
const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote
|
|
71
|
+
const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|закаж|закажите|купить|узнать цен|проверить сайт|начать/i;
|
|
72
72
|
const hrefsLower = links.map((h) => h.toLowerCase());
|
|
73
73
|
const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
|
|
74
74
|
const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
|
|
@@ -118,8 +118,17 @@ function analysePage(url, html) {
|
|
|
118
118
|
const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
|
|
119
119
|
const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
|
|
120
120
|
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
121
|
+
// A source is named either in words, or the way careful writing actually does it: a link to
|
|
122
|
+
// somebody else's site sitting in the same paragraph as the figure it backs.
|
|
121
123
|
const sourcePhrases = (text.match(/\b(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank)\b/gi) || []).length;
|
|
122
|
-
|
|
124
|
+
const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
|
|
125
|
+
const citedParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => {
|
|
126
|
+
if (!/\d/.test(plain(para))) return false;
|
|
127
|
+
return [...para.matchAll(/<a[^>]+href=["'](https?:\/\/[^"']+)["']/gi)].some((m) => {
|
|
128
|
+
try { return new URL(m[1]).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
|
|
129
|
+
});
|
|
130
|
+
}).length;
|
|
131
|
+
return { url, title, citedParagraphs, 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 };
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
async function readSitemap(url, seen = new Set(), depth = 0) {
|
|
@@ -203,7 +212,13 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
203
212
|
log(`sampling up to ${pages} pages`);
|
|
204
213
|
const norm = (u) => { try { const x = new URL(u); x.hash = ''; x.search = ''; if (!x.pathname.includes('.') && !x.pathname.endsWith('/')) x.pathname += '/'; return x.href.toLowerCase(); } catch { return u; } };
|
|
205
214
|
const seenUrl = new Set([norm(home.final), norm(startUrl)]);
|
|
206
|
-
const
|
|
215
|
+
const rest = sitemapPages.filter((u) => { const n = norm(u); if (seenUrl.has(n)) return false; seenUrl.add(n); return true; });
|
|
216
|
+
// The contact, about and policy pages are what the trust and conversion checks are about, so they
|
|
217
|
+
// are read first rather than left to the luck of sitemap order. A 44 page site sampled 20 deep was
|
|
218
|
+
// reporting "no messenger and no contact page" while both sat in the sitemap, unread.
|
|
219
|
+
const TRUST_PAGE = /\/(contact|contacts|contact-us|kontakt|kontakty|about|about-us|o-nas|team|company|privacy|privacy-policy|politika[a-z-]*|terms|terms-of-use|usloviya|oferta|legal|impressum)\/?$/i;
|
|
220
|
+
const trustFirst = rest.filter((u) => { try { return TRUST_PAGE.test(new URL(u).pathname); } catch { return false; } });
|
|
221
|
+
const pool = [home.final, ...trustFirst, ...rest.filter((u) => !trustFirst.includes(u))];
|
|
207
222
|
const sample = [];
|
|
208
223
|
for (const u of pool.slice(0, pages)) {
|
|
209
224
|
const r = await get(u);
|
|
@@ -248,8 +263,8 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
248
263
|
checks.push(row('sections', 'aeo', 'Section structure', structured === good.length ? 'ok' : structured ? 'warn' : 'bad', `${structured} of ${good.length} sampled page(s) carry three or more H2 sections`, structured === good.length ? '' : 'engines quote sections, not walls of text'));
|
|
249
264
|
const withTables = good.filter((p) => p.tables > 0).length;
|
|
250
265
|
checks.push(row('tables', 'aeo', 'Tables in the content', withTables ? 'ok' : 'warn', withTables ? `${withTables} of ${good.length} sampled page(s) use a table` : 'no table on the sampled pages', withTables ? '' : 'tables are the second most quoted format after the opening paragraph'));
|
|
251
|
-
const sourced = good.filter((p) => p.sourcePhrases > 0).length;
|
|
252
|
-
checks.push(row('sources', 'content', 'Sources named in the text', sourced === good.length ? 'ok' : sourced ? 'warn' : 'bad', `${sourced} of ${good.length} sampled page(s) name where a figure comes from`, sourced === good.length ? '' : 'an unsourced figure is the first thing an engine drops'));
|
|
266
|
+
const sourced = good.filter((p) => p.sourcePhrases > 0 || p.citedParagraphs > 0).length;
|
|
267
|
+
checks.push(row('sources', 'content', 'Sources named in the text', sourced === good.length ? 'ok' : sourced ? 'warn' : 'bad', `${sourced} of ${good.length} sampled page(s) name where a figure comes from, in words or as an outbound link beside the figure`, sourced === good.length ? '' : 'an unsourced figure is the first thing an engine drops'));
|
|
253
268
|
}
|
|
254
269
|
const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
|
|
255
270
|
if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
|
|
@@ -268,8 +283,13 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
268
283
|
}
|
|
269
284
|
const addr = anyTrust((p) => p.trust?.postalAddress);
|
|
270
285
|
checks.push(row('trust-entity', 'offpage', 'Who the business is, in machine-readable form', addr ? 'ok' : 'warn', addr ? 'a postal address is published in the structured data' : 'no postal address in the structured data', addr ? '' : 'rating systems and answer engines use it to tell one business from another'));
|
|
271
|
-
|
|
272
|
-
|
|
286
|
+
// A published messenger is a direct channel like any other. Counting only phone and email would
|
|
287
|
+
// mark a business that answers on Telegram all day as unreachable.
|
|
288
|
+
const tel = anyTrust((p) => p.trust?.telephone);
|
|
289
|
+
const mail = anyTrust((p) => p.trust?.email);
|
|
290
|
+
const msgr = anyTrust((p) => p.messengerLinks > 0);
|
|
291
|
+
const channels = [tel ? 'phone' : '', mail ? 'email' : '', msgr ? 'a messenger' : ''].filter(Boolean);
|
|
292
|
+
checks.push(row('trust-contact', 'offpage', 'Direct contact details', channels.length >= 2 ? 'ok' : channels.length ? 'warn' : 'bad', channels.length ? channels.join(' and ') : 'no phone, email or messenger is published'));
|
|
273
293
|
const ownHost = host.replace(/^www\./, '');
|
|
274
294
|
const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
|
|
275
295
|
checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'the cheapest off-site signal there is, and it is free'));
|