@operstack/audit 0.3.0 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/collect.mjs +32 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.3.0",
3
+ "version": "0.3.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
@@ -77,7 +77,19 @@ function analysePage(url, html) {
77
77
  const formEls = root.querySelectorAll('form');
78
78
  const realForms = formEls.filter((f) => !/search/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''}`));
79
79
  const firstForm = realForms[0] || null;
80
- const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).length : 0;
80
+ // A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
81
+ const visibleToVisitor = (el) => {
82
+ if (el.getAttribute('tabindex') === '-1') return false;
83
+ if (/honeypot|\bhp\b/i.test(`${el.getAttribute('class') || ''} ${el.getAttribute('name') || ''}`)) return false;
84
+ for (let n = el.parentNode; n; n = n.parentNode) {
85
+ if (typeof n.getAttribute !== 'function') break;
86
+ if (n.getAttribute('aria-hidden') === 'true') return false;
87
+ if (/honeypot|\bhp\b/i.test(n.getAttribute('class') || '')) return false;
88
+ if (/display:\s*none|visibility:\s*hidden/i.test(n.getAttribute('style') || '')) return false;
89
+ }
90
+ return true;
91
+ };
92
+ const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
81
93
  const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
82
94
  const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
83
95
  const firstScreen = bodyInner.slice(0, Math.max(1200, Math.round(bodyInner.length * 0.15)));
@@ -106,8 +118,17 @@ function analysePage(url, html) {
106
118
  const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
107
119
  const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
108
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.
109
123
  const sourcePhrases = (text.match(/\b(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank)\b/gi) || []).length;
110
- return { url, title, 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 };
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 };
111
132
  }
112
133
 
113
134
  async function readSitemap(url, seen = new Set(), depth = 0) {
@@ -236,8 +257,8 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
236
257
  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'));
237
258
  const withTables = good.filter((p) => p.tables > 0).length;
238
259
  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'));
239
- const sourced = good.filter((p) => p.sourcePhrases > 0).length;
240
- 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'));
260
+ const sourced = good.filter((p) => p.sourcePhrases > 0 || p.citedParagraphs > 0).length;
261
+ 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'));
241
262
  }
242
263
  const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
243
264
  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'));
@@ -256,8 +277,13 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
256
277
  }
257
278
  const addr = anyTrust((p) => p.trust?.postalAddress);
258
279
  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'));
259
- const tel = anyTrust((p) => p.trust?.telephone); const mail = anyTrust((p) => p.trust?.email);
260
- checks.push(row('trust-contact', 'offpage', 'Direct contact details', tel && mail ? 'ok' : tel || mail ? 'warn' : 'bad', [tel ? 'phone' : '', mail ? 'email' : ''].filter(Boolean).join(' and ') || 'neither a phone nor an email is published'));
280
+ // A published messenger is a direct channel like any other. Counting only phone and email would
281
+ // mark a business that answers on Telegram all day as unreachable.
282
+ const tel = anyTrust((p) => p.trust?.telephone);
283
+ const mail = anyTrust((p) => p.trust?.email);
284
+ const msgr = anyTrust((p) => p.messengerLinks > 0);
285
+ const channels = [tel ? 'phone' : '', mail ? 'email' : '', msgr ? 'a messenger' : ''].filter(Boolean);
286
+ 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'));
261
287
  const ownHost = host.replace(/^www\./, '');
262
288
  const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
263
289
  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'));