@operstack/audit 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OperStack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @operstack/audit
2
+
3
+ The SEO, AEO and GEO audit generator behind the OperStack audit service. It collects the public signals of a site into one JSON file, leaves the narrative to an analyst or an agent, and renders a print-ready report.
4
+
5
+ ```
6
+ npx @operstack/audit collect https://example.com --pages 20 --out audit.json
7
+ npx @operstack/audit check audit.json
8
+ npx @operstack/audit render audit.json --pdf
9
+ ```
10
+
11
+ ## What `collect` gathers
12
+
13
+ Everything an external auditor can see without Search Console or analytics access:
14
+
15
+ - HTTPS, www canonicalisation, time to first byte on the homepage.
16
+ - robots.txt, the sitemap it names (or the usual paths), the URL count, foreign hosts in the sitemap, sitemap URLs that redirect or break.
17
+ - llms.txt: present, text or HTML, and whether its links point at the site or somewhere else.
18
+ - The homepage and a sample of pages from the sitemap: title and description lengths, shortcodes or encoded data in the description, H1 count, alt coverage, canonical, robots meta, viewport (zoom blocked), Open Graph and Twitter cards, JSON-LD types (FAQPage, Organization and business types), script and stylesheet counts, hreflang, exposed dates, word counts, duplicate titles, thin pages, utility pages in the index, and on WordPress the xmlrpc endpoint.
19
+
20
+ Every finding becomes a row with a status (`ok`, `warn`, `bad`, `na`), a value and a comment. `bad` rows are copied into the critical-issues page automatically. Scores per group are suggested from the statuses; the analyst can overwrite them.
21
+
22
+ ## What the analyst writes
23
+
24
+ `check` lists every narrative field still holding a `{{placeholder}}`: the client name and subject, the executive summary and verdict, three priorities, the overview note, content strengths and gaps, the AEO and GEO commentary, off-page, conversion rows, the roadmap and the closing message. The rendered report highlights any placeholder in yellow so a draft cannot ship by accident.
25
+
26
+ An agent can fill these fields from the JSON: the Claude Code plugin `operstack-seo` reads `audit.json`, writes the narrative and calls `render`.
27
+
28
+ ## What `render` produces
29
+
30
+ Ten A4 pages: cover, executive summary with a scorecard, site overview with the sampled pages, critical issues, technical and on-page checklist, content and structure, AEO and GEO, off-page and conversion and limitations, roadmap in three phases, closing message. HTML always; PDF with `--pdf` when Chrome or Chromium is installed (`CHROME_PATH` overrides the lookup).
31
+
32
+ ## Example
33
+
34
+ `examples/sample-audit.json` is a complete, fictional audit of "Example Villas". `npm run sample` renders it to HTML and PDF next to the JSON.
35
+
36
+ ## Limits
37
+
38
+ The external tier reads public pages only. It does not run a lab performance test, does not query keyword or backlink tools, and does not see impressions or conversions. The report says so on its limitations page.
39
+
40
+ ## License
41
+
42
+ MIT. Built by OperStack.
package/bin/audit.mjs ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OperStack audit generator.
4
+ *
5
+ * operstack-audit collect https://example.com [--pages 20] [--out audit.json]
6
+ * fetches the public signals (robots, sitemaps, llms.txt, sampled pages) and writes
7
+ * audit.json with every technical row filled and the narrative fields left for the analyst
8
+ * operstack-audit render audit.json [--out report.html] [--pdf]
9
+ * renders the report from the JSON; --pdf also prints a PDF with headless Chrome
10
+ * operstack-audit check audit.json
11
+ * lists narrative fields still holding placeholders
12
+ */
13
+ import { collect } from '../src/collect.mjs';
14
+ import { render, checkNarrative } from '../src/render.mjs';
15
+ import { writeFileSync, readFileSync } from 'node:fs';
16
+ import { resolve } from 'node:path';
17
+
18
+ const [cmd, target, ...rest] = process.argv.slice(2);
19
+ const opt = (k, d) => { const i = rest.indexOf(k); return i === -1 ? d : rest[i + 1]; };
20
+ const has = (k) => rest.includes(k);
21
+
22
+ if (!cmd || cmd === '--help' || cmd === '-h') {
23
+ console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
24
+ process.exit(cmd ? 0 : 2);
25
+ }
26
+ if (cmd === 'collect') {
27
+ if (!/^https?:\/\//.test(target || '')) { console.error('collect needs an absolute URL'); process.exit(2); }
28
+ const audit = await collect(target, { pages: Number(opt('--pages', 20)), log: (m) => console.error(` ${m}`) });
29
+ const out = resolve(opt('--out', 'audit.json'));
30
+ writeFileSync(out, JSON.stringify(audit, null, 2));
31
+ console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, suggested scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v}`).join(', ')}`);
32
+ console.log(`next: fill the narrative fields (operstack-audit check ${out} lists them), then render.`);
33
+ } else if (cmd === 'render') {
34
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
35
+ const out = resolve(opt('--out', target.replace(/\.json$/, '') + '.html'));
36
+ const { html, pdf } = await render(audit, { out, pdf: has('--pdf') });
37
+ console.log(`wrote ${out} (${html.length} bytes)${pdf ? `\nwrote ${pdf}` : ''}`);
38
+ const missing = checkNarrative(audit);
39
+ if (missing.length) console.log(`note: ${missing.length} narrative field(s) still carry placeholders: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? ', ...' : ''}`);
40
+ } else if (cmd === 'check') {
41
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
42
+ const missing = checkNarrative(audit);
43
+ if (!missing.length) console.log('every narrative field is written');
44
+ else { console.log(`${missing.length} field(s) to write:`); for (const m of missing) console.log(` ${m}`); process.exit(1); }
45
+ } else { console.error(`unknown command ${cmd}`); process.exit(2); }
@@ -0,0 +1,79 @@
1
+ {
2
+ "meta": { "site": "https://www.example-villas.example/", "host": "www.example-villas.example", "collectedAt": "2026-09-10T09:00:00.000Z", "tool": "@operstack/audit 0.1.0", "auditType": "External audit (no Search Console or analytics access)" },
3
+ "client": { "name": "Example Villas", "subject": "A pool villa developer's site in a fictional resort market: technical SEO, content, answer-engine readiness and a 90 day plan.", "reportDate": "2026-09-10", "preparedBy": "OperStack" },
4
+ "scores": { "SEO, technical": 3, "SEO, content and structure": 4, "AEO, answers and snippets": 5, "GEO, visibility in AI systems": 2, "Off-page and trust": 6, "Conversion and UX": 5 },
5
+ "scoreNotes": { "SEO, technical": "Broken sitemap, slow first byte, template leftovers", "SEO, content and structure": "A blog exists; the homepage sells three things at once", "AEO, answers and snippets": "FAQ schema present, answers shallow", "GEO, visibility in AI systems": "llms.txt points at a demo domain", "Off-page and trust": "Portal listings exist, brand name collides", "Conversion and UX": "Form works, price is a placeholder" },
6
+ "summary": {
7
+ "lead": "The site is a working marketing page for a villa developer. Search engines index about 18 pages, an eight-article blog is live and FAQ markup is in place. It does not work as a search engine asset yet: the sitemap answers 404, the homepage description carries a form shortcode, and the AI index describes another business.",
8
+ "verdict": "The site is not invisible, it is misread. Three technical defects and a homepage that sells three products at once cost it most of its organic potential. Every critical item can be fixed inside two weeks.",
9
+ "priorities": ["Technical foundation: sitemap, meta description, llms.txt, utility pages out of the index.", "Demand-led structure: one page per project, one per location, one buyer guide.", "Trust content: sourced ownership answers, comparison tables and a FAQ that answer engines can quote."]
10
+ },
11
+ "overview": {
12
+ "rows": [["CMS / stack", "WordPress, page builder, commerce plugin, forms plugin"], ["Pages in sitemap", "21"], ["Language", "English, single version"], ["What is sold", "Two villa projects presented on one homepage"]],
13
+ "note": "Three offers share the homepage: a price headline with a placeholder, a block about a seven-villa project and a block about a second phase with overlapping copy. A visitor and a crawler both leave unsure which product the site sells."
14
+ },
15
+ "critical": [
16
+ { "title": "XML sitemap answers 404", "text": "robots.txt points at sitemap_index.xml and the server returns 404. Search engines crawl without a map: slower and less complete.", "level": "bad" },
17
+ { "title": "Homepage meta description contains a form shortcode", "text": "The description field holds an encoded form tag. In results it renders as technical garbage instead of a sales sentence.", "level": "bad" },
18
+ { "title": "llms.txt describes a demo site", "text": "The file exists, which is a positive signal, but its links go to a template demo domain with unrelated products. An AI system reading it may misidentify the business.", "level": "bad" },
19
+ { "title": "Commerce utility pages are indexable", "text": "Shop, compare and wishlist pages carry index directives and demo products. They add noise to a 21 page index.", "level": "warn" }
20
+ ],
21
+ "checks": [
22
+ { "id": "https", "group": "technical", "label": "HTTPS", "status": "ok", "value": "certificate active" },
23
+ { "id": "www", "group": "technical", "label": "www canonicalisation", "status": "ok", "value": "non-www redirects to www (301)" },
24
+ { "id": "ttfb", "group": "technical", "label": "Time to first byte (homepage)", "status": "bad", "value": "3,300 ms", "comment": "single request from the auditor, not a lab test" },
25
+ { "id": "robots", "group": "technical", "label": "robots.txt", "status": "warn", "value": "3 disallow rule(s), 1 sitemap line(s)", "comment": "the sitemap it names answers 404" },
26
+ { "id": "sitemap", "group": "technical", "label": "XML sitemap", "status": "bad", "value": "sitemap_index.xml answers HTTP 404" },
27
+ { "id": "canonical", "group": "technical", "label": "Canonical tag", "status": "ok", "value": "https://www.example-villas.example/" },
28
+ { "id": "viewport", "group": "technical", "label": "Mobile viewport", "status": "warn", "value": "width=device-width, initial-scale=1, user-scalable=no", "comment": "blocks zoom: bad for accessibility and mobile SEO" },
29
+ { "id": "og", "group": "technical", "label": "Open Graph and Twitter cards", "status": "warn", "value": "og:title, og:image, twitter:summary_large_image", "comment": "description carries the same shortcode" },
30
+ { "id": "resources", "group": "technical", "label": "Page weight (homepage)", "status": "bad", "value": "39 script file(s), 14 stylesheet(s), 39 image(s)" },
31
+ { "id": "title", "group": "onpage", "label": "Homepage title", "status": "warn", "value": "\"Home - Example Villas\" (21 chars)", "comment": "short: the words that earn the click are missing" },
32
+ { "id": "description", "group": "onpage", "label": "Homepage meta description", "status": "bad", "value": "contains a shortcode or encoded data" },
33
+ { "id": "h1", "group": "onpage", "label": "H1 on homepage", "status": "warn", "value": "3 H1 tag(s)", "comment": "more than one H1 dilutes the page topic" },
34
+ { "id": "alt", "group": "onpage", "label": "Image alt text", "status": "bad", "value": "3 of 39 images carry alt text (8%) across 12 sampled page(s)" },
35
+ { "id": "h1-sample", "group": "onpage", "label": "Heading structure across sample", "status": "bad", "value": "9 of 12 pages carry more than one H1" },
36
+ { "id": "thin", "group": "content", "label": "Thin pages in sample", "status": "ok", "value": "median 1,600 words per page" },
37
+ { "id": "dates", "group": "content", "label": "Publication dates exposed", "status": "warn", "value": "modified dates precede published dates on 3 articles" },
38
+ { "id": "schema", "group": "aeo", "label": "Structured data on homepage", "status": "ok", "value": "RealEstateAgent, Organization, FAQPage" },
39
+ { "id": "faq-schema", "group": "aeo", "label": "FAQPage schema", "status": "ok", "value": "present on 1 sampled page(s)" },
40
+ { "id": "llms", "group": "geo", "label": "llms.txt", "status": "bad", "value": "14 link(s), 12 to other hosts", "comment": "links to a template demo domain: an AI system may misidentify the business" },
41
+ { "id": "org-schema", "group": "geo", "label": "Organization or business schema", "status": "ok", "value": "present" }
42
+ ],
43
+ "content": {
44
+ "strengths": ["FAQ block on the homepage, a base for answer engines", "Enquiry form with a budget field", "Location block with real distances", "Key features with figures: 7 villas, 240 sqm plots"],
45
+ "weaknesses": ["Title and description do not name the product or the place", "No single H1 with the project name and a real price", "The ownership FAQ is one sentence where buyers need a table", "Two different office addresses in the footer"],
46
+ "gaps": ["/projects/<name>/: prices, floor plans, gallery, FAQ", "/location/<district>/: map, distances, infrastructure", "/foreign-buyers-guide/: ownership routes, fees, timeline", "/availability/: current prices and stock"]
47
+ },
48
+ "aeo": {
49
+ "works": ["FAQPage schema with seven questions", "Organization and business schema", "Question and answer format in the FAQ block", "Blog articles cover informational intent"],
50
+ "blocks": ["Ownership answers too short: no lease route, no risks", "No comparison tables for ownership options", "No sourced yield or price figures", "No expert bylines"],
51
+ "recommendation": "Publish one buyer guide with tables on ownership routes, fees and timeline, written so that a single paragraph can be quoted as the answer."
52
+ },
53
+ "geo": {
54
+ "rows": [["Entity clarity", "△ Weak", "Schema present, but content confuses two project names"], ["Brand disambiguation", "✗ Problem", "The brand name matches a residential district in another country"], ["Third-party mentions", "✓ Present", "Listings on three property portals"], ["Reviews and PR", "✗ None", "No independent reviews found"]],
55
+ "callout": "Brand collision: for the bare brand name, search engines show a district elsewhere rather than the developer. Use the brand together with the place in titles, schema and external profiles."
56
+ },
57
+ "offpage": {
58
+ "listed": ["Two property portals carry the project with prices", "One aggregator carries a developer profile", "One agency page carries villa specifications"],
59
+ "note": "Portal listings drive discovery, but the authority accrues to the portals. The owned site's job is to become the primary source the portals link to."
60
+ },
61
+ "conversion": {
62
+ "rows": [["Enquiry form", "✓ Works, budget field present"], ["Price on homepage", "✗ Placeholder instead of a figure"], ["Footer address", "✗ Two different addresses"], ["Call to action", "△ No clear next step"], ["Messengers", "△ Phone and email only"], ["Commerce plugin for 3 villas", "△ Adds weight without benefit"]]
63
+ },
64
+ "limitations": ["Google Search Console: no impressions, clicks or query data", "Analytics: no traffic or behaviour data", "Keyword and backlink tools: not used in the external tier", "Lab performance report: not run; the first-byte figure is a single request"],
65
+ "roadmap": [
66
+ { "badge": "Week 1", "title": "Critical, quick wins", "items": ["Fix the XML sitemap and confirm the URL in robots.txt answers 200", "Rewrite the homepage title and description; remove the shortcode", "Rewrite llms.txt for the actual business; remove demo links", "Set noindex on shop, compare and wishlist", "One H1 on the homepage with the project name and a real starting price", "Remove user-scalable=no"] },
67
+ { "badge": "Weeks 2 to 4", "title": "Foundation", "items": ["Project page with prices, plans and FAQ", "Buyer guide with ownership routes and fees", "Location page with map and distances", "Alt text on every image", "Search Console and Bing Webmaster connected", "One product line on the homepage, or two clearly separated"] },
68
+ { "badge": "Months 2 to 3", "title": "Organic growth, AEO and GEO", "items": ["12 to 15 articles by intent cluster", "Product and place schema on villa pages", "PR and mentions in local property media", "Business profile for the office and the project location", "FAQ expanded to 15 to 20 sourced answers", "Comparison tables for ownership, fees and timeline"] }
69
+ ],
70
+ "closing": "A beautiful site and a search asset are not the same thing. This one is pointed the right way, with a blog, a FAQ and schema, but was never cleaned up after launching on a template. The critical items take two weeks and show in Search Console within 30 to 60 days.",
71
+ "sample": [
72
+ { "url": "https://www.example-villas.example/", "title": "Home - Example Villas", "words": 940, "h1Count": 3, "images": 21, "imagesNoAlt": 19, "schemaTypes": ["RealEstateAgent", "Organization", "FAQPage"] },
73
+ { "url": "https://www.example-villas.example/about/", "title": "About - Example Villas", "words": 420, "h1Count": 2, "images": 6, "imagesNoAlt": 6, "schemaTypes": [] },
74
+ { "url": "https://www.example-villas.example/blog/why-invest/", "title": "Why invest in a pool villa", "words": 1610, "h1Count": 2, "images": 4, "imagesNoAlt": 4, "schemaTypes": ["Article"] },
75
+ { "url": "https://www.example-villas.example/blog/location/", "title": "The location explained", "words": 1580, "h1Count": 2, "images": 5, "imagesNoAlt": 4, "schemaTypes": ["Article"] },
76
+ { "url": "https://www.example-villas.example/contact/", "title": "Contact - Example Villas", "words": 180, "h1Count": 1, "images": 3, "imagesNoAlt": 3, "schemaTypes": [] }
77
+ ],
78
+ "notes": []
79
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@operstack/audit",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "OperStack",
8
+ "bin": { "operstack-audit": "bin/audit.mjs" },
9
+ "exports": { ".": "./src/index.mjs" },
10
+ "files": ["bin", "src", "templates", "examples/sample-audit.json", "README.md", "LICENSE"],
11
+ "scripts": {
12
+ "test": "node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node -e \"const s=require('fs').readFileSync('/tmp/operstack-audit-sample.html','utf8'); if(!s.includes('Executive summary')) process.exit(1); console.log('render ok', s.length, 'bytes')\"",
13
+ "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
14
+ },
15
+ "keywords": ["seo", "aeo", "geo", "audit", "llms-txt", "site-audit", "report"],
16
+ "engines": { "node": ">=20" },
17
+ "dependencies": { "node-html-parser": "^9.0.4" }
18
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Collect public signals from a site. Everything here is what an external auditor sees:
3
+ * robots.txt, sitemaps, llms.txt, the homepage and a sample of pages from the sitemap.
4
+ * No Search Console, no analytics. The narrative fields are left for the analyst.
5
+ */
6
+ import { parse } from 'node-html-parser';
7
+
8
+ const UA = 'Mozilla/5.0 (compatible; OperStackAudit/0.1; +https://oper-stack.com)';
9
+
10
+ async function get(url, { method = 'GET', timeout = 15000 } = {}) {
11
+ const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
12
+ const started = Date.now();
13
+ try {
14
+ const res = await fetch(url, { method, redirect: 'manual', signal: c.signal, headers: { 'user-agent': UA, accept: 'text/html,application/xml,text/plain,*/*' } });
15
+ const ttfb = Date.now() - started;
16
+ const text = method === 'GET' ? await res.text() : '';
17
+ return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', text, ttfb, url };
18
+ } catch (e) { return { ok: false, status: 0, error: e.name === 'AbortError' ? 'timeout' : e.message, text: '', ttfb: Date.now() - started, url }; }
19
+ finally { clearTimeout(t); }
20
+ }
21
+
22
+ async function follow(url, max = 5) {
23
+ const hops = [];
24
+ let cur = url;
25
+ for (let i = 0; i < max; i++) {
26
+ const r = await get(cur);
27
+ hops.push({ url: cur, status: r.status, location: r.location });
28
+ if (r.status >= 300 && r.status < 400 && r.location) { cur = new URL(r.location, cur).href; continue; }
29
+ return { final: cur, response: r, hops };
30
+ }
31
+ return { final: cur, response: await get(cur), hops };
32
+ }
33
+
34
+ const row = (id, group, label, status, value, comment = '') => ({ id, group, label, status, value, comment });
35
+
36
+ function analysePage(url, html) {
37
+ const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
38
+ const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
39
+ const title = root.querySelector('title')?.text.trim() ?? '';
40
+ const description = attr('meta[name="description"]', 'content').trim();
41
+ const h1s = root.querySelectorAll('h1').map((h) => h.text.trim()).filter(Boolean);
42
+ const imgs = root.querySelectorAll('img');
43
+ const imgsNoAlt = imgs.filter((i) => !(i.getAttribute('alt') || '').trim()).length;
44
+ const canonical = attr('link[rel="canonical"]', 'href');
45
+ const robots = attr('meta[name="robots"]', 'content');
46
+ const viewport = attr('meta[name="viewport"]', 'content');
47
+ const og = { title: attr('meta[property="og:title"]', 'content'), description: attr('meta[property="og:description"]', 'content'), image: attr('meta[property="og:image"]', 'content') };
48
+ const twitter = attr('meta[name="twitter:card"]', 'content');
49
+ const generator = attr('meta[name="generator"]', 'content');
50
+ const hreflang = root.querySelectorAll('link[rel="alternate"][hreflang]').length;
51
+ const scripts = root.querySelectorAll('script[src]').length;
52
+ const stylesheets = root.querySelectorAll('link[rel="stylesheet"]').length;
53
+ const schemaTypes = [];
54
+ for (const s of root.querySelectorAll('script[type="application/ld+json"]')) {
55
+ try {
56
+ const j = JSON.parse(s.text);
57
+ const walk = (x) => { if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') { if (x['@type']) schemaTypes.push(...[].concat(x['@type'])); if (x['@graph']) walk(x['@graph']); } };
58
+ walk(j);
59
+ } catch { schemaTypes.push('(invalid JSON-LD)'); }
60
+ }
61
+ const body = root.querySelector('body');
62
+ const text = (body?.text ?? '').replace(/\s+/g, ' ').trim();
63
+ const words = (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
64
+ const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
65
+ const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
66
+ 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] || '' };
67
+ const shortcode = /\[[a-z_-]+ [^\]]*\]/i.test(description) || /^[A-Za-z0-9+/=]{40,}$/.test(description);
68
+ 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 };
69
+ }
70
+
71
+ async function readSitemap(url, seen = new Set(), depth = 0) {
72
+ if (seen.has(url) || depth > 3) return [];
73
+ seen.add(url);
74
+ const r = await get(url);
75
+ if (!r.ok) return [];
76
+ const locs = [...r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
77
+ const out = [];
78
+ for (const l of locs) {
79
+ if (/\.xml(\.gz)?$/i.test(l) || /sitemap/i.test(l) && !/\.(html?|php)$/i.test(l) && r.text.includes('<sitemapindex')) out.push(...(await readSitemap(l, seen, depth + 1)));
80
+ else out.push(l);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
86
+ const origin = new URL(startUrl).origin;
87
+ const host = new URL(startUrl).host;
88
+ const checks = [];
89
+ const notes = [];
90
+
91
+ log(`homepage ${startUrl}`);
92
+ const home = await follow(startUrl);
93
+ const homePage = home.response.ok ? analysePage(home.final, home.response.text) : null;
94
+ checks.push(row('https', 'technical', 'HTTPS', home.final.startsWith('https://') ? 'ok' : 'bad', home.final.startsWith('https://') ? 'certificate active' : 'site served over http'));
95
+ const altHost = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
96
+ const alt = await follow(`${new URL(home.final).protocol}//${altHost}/`);
97
+ const altOk = alt.final.replace(/\/$/, '') === new URL(home.final).origin.replace(/\/$/, '') || alt.hops.some((h) => h.status >= 300 && h.status < 400);
98
+ checks.push(row('www', 'technical', 'www canonicalisation', alt.response.status === 0 ? 'warn' : altOk ? 'ok' : 'warn', alt.response.status === 0 ? `${altHost} does not resolve` : altOk ? `${altHost} redirects to ${new URL(home.final).host}` : `${altHost} answers ${alt.response.status} without redirecting`));
99
+ checks.push(row('ttfb', 'technical', 'Time to first byte (homepage)', home.response.ttfb > 2500 ? 'bad' : home.response.ttfb > 1200 ? 'warn' : 'ok', `${home.response.ttfb} ms`, 'single request from the auditor, not a lab test'));
100
+
101
+ log('robots.txt');
102
+ const robots = await get(`${origin}/robots.txt`);
103
+ const sitemapUrls = robots.ok ? [...robots.text.matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]) : [];
104
+ // robots.txt is read per user-agent block: a "Disallow: /" under an AI crawler is a policy, under * it is a catastrophe.
105
+ const blocks = []; let cur = null;
106
+ for (const line of robots.ok ? robots.text.split(/\r?\n/) : []) {
107
+ const l = line.replace(/#.*$/, '').trim(); if (!l) continue;
108
+ const m = l.match(/^([a-z-]+):\s*(.*)$/i); if (!m) continue;
109
+ const [, k, v] = m; const key = k.toLowerCase();
110
+ if (key === 'user-agent') { if (!cur || cur.rules.length) { cur = { agents: [], rules: [] }; blocks.push(cur); } cur.agents.push(v.trim().toLowerCase()); }
111
+ else if (cur && (key === 'disallow' || key === 'allow')) cur.rules.push({ key, value: v.trim() });
112
+ }
113
+ const disallows = blocks.flatMap((b) => b.rules.filter((r) => r.key === 'disallow' && r.value).map((r) => r.value));
114
+ const starBlocksAll = blocks.some((b) => b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/'));
115
+ const blockedAgents = blocks.filter((b) => !b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/')).flatMap((b) => b.agents);
116
+ checks.push(row('robots', 'technical', 'robots.txt', robots.ok ? (sitemapUrls.length ? 'ok' : 'warn') : 'bad', robots.ok ? `${disallows.length} disallow rule(s) in ${blocks.length} agent block(s), ${sitemapUrls.length} sitemap line(s)` : `HTTP ${robots.status}`, robots.ok && !sitemapUrls.length ? 'no Sitemap: line' : ''));
117
+ if (starBlocksAll) checks.push(row('robots-block', 'technical', 'robots.txt blocks the whole site', 'bad', 'Disallow: / under User-agent: *', 'search engines are told not to crawl anything'));
118
+ if (blockedAgents.length) checks.push(row('robots-ai', 'geo', 'AI crawlers blocked in robots.txt', 'na', `${blockedAgents.length} agent(s) fully disallowed, e.g. ${blockedAgents.slice(0, 4).join(', ')}`, 'a policy choice: these systems will not read the site or cite it from a fresh crawl'));
119
+
120
+ log('sitemaps');
121
+ const candidates = sitemapUrls.length ? sitemapUrls : [`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`];
122
+ let sitemapPages = []; let sitemapStatus = 'missing'; let sitemapSource = '';
123
+ for (const u of candidates) {
124
+ const r = await get(u);
125
+ if (r.ok && /<(urlset|sitemapindex)/.test(r.text)) { sitemapPages = await readSitemap(u); sitemapStatus = 'ok'; sitemapSource = u; break; }
126
+ if (sitemapUrls.includes(u)) { sitemapStatus = `HTTP ${r.status}`; sitemapSource = u; }
127
+ }
128
+ checks.push(row('sitemap', 'technical', 'XML sitemap', sitemapStatus === 'ok' ? 'ok' : 'bad', sitemapStatus === 'ok' ? `${sitemapPages.length} URL(s) in ${sitemapSource}` : sitemapSource ? `${sitemapSource} answers ${sitemapStatus}` : 'no sitemap found at the usual paths', sitemapStatus !== 'ok' && sitemapUrls.length ? 'robots.txt points at a sitemap that does not answer' : ''));
129
+ const foreign = sitemapPages.filter((u) => { try { return new URL(u).host !== host && new URL(u).host !== altHost; } catch { return true; } });
130
+ if (foreign.length) checks.push(row('sitemap-foreign', 'technical', 'Sitemap lists other hosts', 'bad', `${foreign.length} URL(s) on other hosts, e.g. ${foreign[0]}`));
131
+
132
+ log('llms.txt');
133
+ const llms = await get(`${origin}/llms.txt`);
134
+ const llmsIsText = llms.ok && !/<html/i.test(llms.text.slice(0, 500));
135
+ const llmsLinks = llmsIsText ? [...llms.text.matchAll(/https?:\/\/[^\s)\]]+/g)].map((m) => m[0]) : [];
136
+ const llmsForeign = llmsLinks.filter((u) => { try { const h = new URL(u).host; return h !== host && h !== altHost; } catch { return false; } });
137
+ checks.push(row('llms', 'geo', 'llms.txt', !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : 'ok',
138
+ !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
139
+ !llms.ok ? 'answer engines get nothing to read; a generated index is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
140
+
141
+ log(`sampling up to ${pages} pages`);
142
+ 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; } };
143
+ const seenUrl = new Set([norm(home.final), norm(startUrl)]);
144
+ const pool = [home.final, ...sitemapPages.filter((u) => { const n = norm(u); if (seenUrl.has(n)) return false; seenUrl.add(n); return true; })];
145
+ const sample = [];
146
+ for (const u of pool.slice(0, pages)) {
147
+ const r = await get(u);
148
+ if (r.ok && /html/i.test(r.type)) sample.push(analysePage(u, r.text));
149
+ else if (r.status >= 300 && r.status < 400) sample.push({ url: u, redirect: r.location, status: r.status });
150
+ else sample.push({ url: u, status: r.status, error: r.error });
151
+ }
152
+ const good = sample.filter((p) => p.title !== undefined);
153
+ const hp = homePage || good[0];
154
+
155
+ if (hp) {
156
+ checks.push(row('title', 'onpage', 'Homepage title', hp.titleLength >= 40 && hp.titleLength <= 60 ? 'ok' : hp.titleLength ? 'warn' : 'bad', `"${hp.title}" (${hp.titleLength} chars)`, hp.titleLength < 40 ? 'short: the words that earn the click are missing' : hp.titleLength > 60 ? 'cut in results' : ''));
157
+ checks.push(row('description', 'onpage', 'Homepage meta description', hp.descriptionGarbage ? 'bad' : hp.descriptionLength >= 70 && hp.descriptionLength <= 160 ? 'ok' : hp.descriptionLength ? 'warn' : 'bad', hp.descriptionGarbage ? 'contains a shortcode or encoded data' : `${hp.descriptionLength} chars`, hp.descriptionGarbage ? 'renders as technical garbage in the snippet' : !hp.descriptionLength ? 'missing' : ''));
158
+ checks.push(row('h1', 'onpage', 'H1 on homepage', hp.h1Count === 1 ? 'ok' : hp.h1Count === 0 ? 'bad' : 'warn', `${hp.h1Count} H1 tag(s)`, hp.h1Count > 1 ? 'more than one H1 dilutes the page topic' : hp.h1Count === 0 ? 'no H1' : ''));
159
+ checks.push(row('canonical', 'technical', 'Canonical tag', hp.canonical ? 'ok' : 'warn', hp.canonical || 'missing'));
160
+ checks.push(row('viewport', 'technical', 'Mobile viewport', !hp.viewport ? 'bad' : /user-scalable\s*=\s*no|maximum-scale\s*=\s*1(\.0)?\b/i.test(hp.viewport) ? 'warn' : 'ok', hp.viewport || 'missing', /user-scalable\s*=\s*no/i.test(hp.viewport) ? 'blocks zoom: bad for accessibility and mobile SEO' : ''));
161
+ checks.push(row('og', 'technical', 'Open Graph and Twitter cards', hp.og.title && hp.og.image ? (hp.twitter ? 'ok' : 'warn') : 'warn', [hp.og.title ? 'og:title' : '', hp.og.image ? 'og:image' : '', hp.twitter ? `twitter:${hp.twitter}` : ''].filter(Boolean).join(', ') || 'none'));
162
+ checks.push(row('schema', 'aeo', 'Structured data on homepage', hp.schemaTypes.length ? (hp.schemaTypes.includes('(invalid JSON-LD)') ? 'warn' : 'ok') : 'warn', hp.schemaTypes.join(', ') || 'no JSON-LD'));
163
+ checks.push(row('faq-schema', 'aeo', 'FAQPage schema', good.some((p) => p.schemaTypes.includes('FAQPage')) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.includes('FAQPage')) ? `present on ${good.filter((p) => p.schemaTypes.includes('FAQPage')).length} sampled page(s)` : 'not found in the sample'));
164
+ checks.push(row('org-schema', 'geo', 'Organization or business schema', good.some((p) => p.schemaTypes.some((t) => /Organization|LocalBusiness|RealEstateAgent|Corporation/.test(t))) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.some((t) => /Organization|LocalBusiness|RealEstateAgent|Corporation/.test(t))) ? 'present' : 'not found: answer engines have no entity to attach the site to'));
165
+ checks.push(row('resources', 'technical', 'Page weight (homepage)', hp.scripts > 30 ? 'bad' : hp.scripts > 15 ? 'warn' : 'ok', `${hp.scripts} script file(s), ${hp.stylesheets} stylesheet(s), ${hp.images} image(s)`));
166
+ if (hp.generator) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator));
167
+ checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
168
+ }
169
+ const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
170
+ if (imgs) checks.push(row('alt', 'onpage', 'Image alt text', noAlt / imgs > 0.5 ? 'bad' : noAlt / imgs > 0.2 ? 'warn' : 'ok', `${imgs - noAlt} of ${imgs} images carry alt text (${Math.round((1 - noAlt / imgs) * 100)}%) across ${good.length} sampled page(s)`));
171
+ const multiH1 = good.filter((p) => p.h1Count > 1).length;
172
+ if (good.length > 1) checks.push(row('h1-sample', 'onpage', 'Heading structure across sample', multiH1 ? (multiH1 > good.length / 2 ? 'bad' : 'warn') : 'ok', multiH1 ? `${multiH1} of ${good.length} pages carry more than one H1` : 'one H1 per page'));
173
+ const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
174
+ const dupTitles = [...titles].filter(([, n]) => n > 1);
175
+ if (good.length > 1) checks.push(row('dup-titles', 'onpage', 'Duplicate titles in sample', dupTitles.length ? 'warn' : 'ok', dupTitles.length ? `${dupTitles.length} title(s) shared by several pages, e.g. "${dupTitles[0][0]}"` : 'every sampled page has its own title'));
176
+ const thin = good.filter((p) => p.words < 300);
177
+ if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
178
+ const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
179
+ if (utility.length) checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, 'cart, wishlist, tag and author pages add noise to the index'));
180
+ const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
181
+ if (sitemapPages.length) checks.push(row('sitemap-health', 'technical', 'Sitemap URLs that answer 200', broken ? 'bad' : redirected ? 'warn' : 'ok', `${sample.length - redirected - broken} of ${sample.length} sampled URLs answer 200${redirected ? `, ${redirected} redirect` : ''}${broken ? `, ${broken} broken` : ''}`));
182
+ const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
183
+ 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'));
184
+ if (/wordpress/i.test(hp?.generator || '')) { const x = await get(`${origin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
185
+
186
+ const scores = suggestScores(checks);
187
+ const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
188
+
189
+ return {
190
+ meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: 'External audit (no Search Console or analytics access)', language: hp ? (hp.og?.locale || '') : '' },
191
+ client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
192
+ scores,
193
+ summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
194
+ overview: { rows: [['CMS / stack', hp?.generator || '{{stack}}'], ['Pages in sitemap', String(sitemapPages.length)], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
195
+ critical,
196
+ checks,
197
+ content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
198
+ aeo: { works: ['{{what already works}}'], blocks: ['{{what blocks answer-engine wins}}'], recommendation: '{{One recommendation with a page name.}}' },
199
+ geo: { rows: [['Entity clarity', '{{status}}', '{{detail}}'], ['Third-party mentions', '{{status}}', '{{detail}}'], ['Reviews and PR', '{{status}}', '{{detail}}']], callout: '{{Brand disambiguation note or the single biggest GEO risk.}}' },
200
+ offpage: { listed: ['{{where the brand is already listed}}'], note: '{{One paragraph.}}' },
201
+ conversion: { rows: [['Contact form', '{{status}}'], ['Price visible', '{{status}}'], ['Messengers', '{{status}}']] },
202
+ limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
203
+ roadmap: [
204
+ { badge: 'Week 1', title: 'Critical, quick wins', items: critical.map((c) => `Fix: ${c.title}`).concat(['{{...}}']) },
205
+ { badge: 'Weeks 2 to 4', title: 'Foundation', items: ['{{pillar pages, alt text, Search Console and Bing connected}}'] },
206
+ { badge: 'Months 2 to 3', title: 'Organic growth, AEO and GEO', items: ['{{content clusters, schema, PR, reviews}}'] },
207
+ ],
208
+ closing: '{{Key message for the client in three sentences.}}',
209
+ sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, words: p.words, h1Count: p.h1Count, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes } : p),
210
+ notes,
211
+ };
212
+ }
213
+
214
+ function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : 0; }
215
+
216
+ /** 0 to 10 per group from the statuses: ok 1, warn 0.5, bad 0; na ignored. An analyst may override. */
217
+ export function suggestScores(checks) {
218
+ const groups = { technical: 'SEO, technical', onpage: 'SEO, content and structure', content: 'SEO, content and structure', aeo: 'AEO, answers and snippets', geo: 'GEO, visibility in AI systems' };
219
+ const acc = {};
220
+ for (const c of checks) {
221
+ const g = groups[c.group]; if (!g || c.status === 'na') continue;
222
+ acc[g] ??= { n: 0, s: 0 }; acc[g].n++; acc[g].s += c.status === 'ok' ? 1 : c.status === 'warn' ? 0.5 : 0;
223
+ }
224
+ const out = {};
225
+ for (const [g, { n, s }] of Object.entries(acc)) out[g] = Math.round((s / n) * 10);
226
+ for (const g of ['Off-page and trust', 'Conversion and UX']) out[g] = out[g] ?? null;
227
+ return out;
228
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ export { collect } from './collect.mjs';
2
+ export { render, checkNarrative } from './render.mjs';
package/src/render.mjs ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Render audit.json into the OperStack report: A4 pages, print-ready, and optionally a PDF
3
+ * through headless Chrome. Every narrative field is read from the JSON; a field that still
4
+ * holds a {{placeholder}} is rendered highlighted so it cannot ship unnoticed.
5
+ */
6
+ import { writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
12
+ const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
13
+ const t = (s) => (isPlaceholder(s) ? `<mark class="todo">${esc(s)}</mark>` : esc(s));
14
+ const STATUS = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['·', 'status-na'] };
15
+ const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
16
+
17
+ export function checkNarrative(audit) {
18
+ const out = [];
19
+ const walk = (v, path) => {
20
+ if (typeof v === 'string') { if (isPlaceholder(v)) out.push(path); }
21
+ else if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${path}[${i}]`));
22
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k);
23
+ };
24
+ for (const k of ['client', 'summary', 'overview', 'critical', 'content', 'aeo', 'geo', 'offpage', 'conversion', 'roadmap', 'closing']) walk(audit[k], k);
25
+ return out;
26
+ }
27
+
28
+ function css() {
29
+ return `
30
+ @page { size: A4; margin: 0; }
31
+ :root { --ink:#14181c; --ink-2:#3b474d; --muted:#5c6b6f; --rule:#d8dedb; --paper:#fff; --panel:#f3f5f6; --accent:#0b7a75; --accent-soft:#dcefed; --gold:#8a6b38; --gold-soft:#f5efe3; --danger:#b3261e; --warn:#9a6700; --ok:#1f7a3d; --pad:16mm; }
32
+ * { box-sizing: border-box; margin: 0; padding: 0; }
33
+ body { font-family: "IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); background: #e9ecec; font-size: 10.2pt; line-height: 1.5; }
34
+ .page { width: 210mm; min-height: 297mm; margin: 0 auto 8mm; background: var(--paper); padding: var(--pad) var(--pad) 22mm; position: relative; page-break-after: always; }
35
+ .page:last-child { page-break-after: auto; }
36
+ h1, h2, h3 { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-weight: 600; color: var(--ink); line-height: 1.15; }
37
+ h2 { font-size: 19pt; margin-bottom: 4mm; padding-bottom: 2mm; border-bottom: 2px solid var(--accent-soft); }
38
+ h3 { font-size: 11.5pt; margin: 6mm 0 2.5mm; }
39
+ p { margin-bottom: 3mm; color: var(--ink-2); }
40
+ p.lead { font-size: 11pt; color: var(--ink); }
41
+ ul, ol { margin: 0 0 4mm 5mm; color: var(--ink-2); } li { margin-bottom: 1.5mm; }
42
+ .eyebrow { font-size: 8pt; letter-spacing: .12em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin-bottom: 2mm; }
43
+ .cover { min-height: calc(297mm - 38mm); display: flex; flex-direction: column; justify-content: space-between; }
44
+ .cover-top { border-top: 3px solid var(--accent); padding-top: 8mm; }
45
+ .cover h1 { font-size: 34pt; margin: 2mm 0 4mm; }
46
+ .cover-sub { font-size: 13pt; color: var(--ink-2); max-width: 85%; margin-bottom: 10mm; }
47
+ .cover-url { display: inline-block; background: var(--accent-soft); color: var(--accent); padding: 2.5mm 4.5mm; border-radius: 4px; font-weight: 600; font-size: 11pt; }
48
+ .cover-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; margin-top: 14mm; padding-top: 6mm; border-top: 1px solid var(--rule); }
49
+ .cover-meta dt { font-size: 7.5pt; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 1mm; }
50
+ .cover-meta dd { font-size: 11pt; font-weight: 600; }
51
+ .cover-foot { font-size: 8.5pt; color: var(--muted); border-top: 1px solid var(--rule); padding-top: 4mm; }
52
+ .page-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 3mm; margin-bottom: 6mm; border-bottom: 1px solid var(--rule); font-size: 8pt; color: var(--muted); }
53
+ .page-header strong { color: var(--accent); }
54
+ .page-footer { position: absolute; bottom: 9mm; left: var(--pad); right: var(--pad); display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: 1px solid var(--rule); padding-top: 2.5mm; }
55
+ .scorecard { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin: 5mm 0 6mm; }
56
+ .score { border: 1px solid var(--rule); border-radius: 4px; padding: 3.5mm 4mm; background: #fafbfb; }
57
+ .score .label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 1.5mm; }
58
+ .score-row { display: flex; align-items: center; justify-content: space-between; gap: 3mm; }
59
+ .score-num { font-family: "Fraunces", Georgia, serif; font-size: 22pt; font-weight: 600; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; }
60
+ .score-num.low { color: var(--danger); } .score-num.mid { color: var(--warn); } .score-num.ok { color: var(--ok); }
61
+ .score-note { font-size: 8.5pt; color: var(--muted); flex: 1; text-align: right; }
62
+ .verdict { background: var(--ink); color: #e8ecef; border-radius: 4px; padding: 5mm 6mm; margin: 5mm 0; }
63
+ .verdict p { color: #e8ecef; margin: 0; }
64
+ table { width: 100%; border-collapse: collapse; margin: 3mm 0 5mm; font-size: 9.2pt; }
65
+ th { text-align: left; padding: 2.5mm 3mm; font-size: 7.8pt; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border-bottom: 2px solid var(--ink); font-weight: 600; }
66
+ td { padding: 2.2mm 3mm; border-bottom: 1px solid var(--rule); vertical-align: top; }
67
+ .status-ok { color: var(--ok); font-weight: 600; white-space: nowrap; } .status-warn { color: var(--warn); font-weight: 600; white-space: nowrap; } .status-bad { color: var(--danger); font-weight: 600; white-space: nowrap; } .status-na { color: var(--muted); }
68
+ .cards { display: grid; gap: 3mm; margin: 4mm 0; }
69
+ .card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
70
+ .card.warn { border-left-color: var(--warn); background: #fdf8ee; }
71
+ .card h4 { font-size: 10pt; margin-bottom: 1.5mm; } .card p { font-size: 9.3pt; margin: 0; }
72
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
73
+ .callout { background: var(--gold-soft); border-radius: 4px; padding: 4mm 5mm; margin: 4mm 0; border: 1px solid #e8dcc8; }
74
+ .callout p { color: var(--ink); margin: 0; font-size: 9.5pt; }
75
+ .phase { margin-bottom: 5mm; } .phase-head { display: flex; align-items: center; gap: 3mm; margin-bottom: 2.5mm; }
76
+ .badge { background: var(--accent); color: #fff; font-size: 7.8pt; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; padding: 1mm 2.5mm; border-radius: 3px; white-space: nowrap; }
77
+ .phase-title { font-weight: 700; font-size: 10.5pt; }
78
+ .todo { background: #ffe58a; color: #5b4300; padding: 0 .3em; }
79
+ code { font-family: "IBM Plex Mono", Menlo, monospace; font-size: 8.8pt; background: var(--panel); padding: 0 .3em; border-radius: 2px; }
80
+ @media print { body { background: #fff; } .page { margin: 0; } }
81
+ `;
82
+ }
83
+
84
+ const header = (audit, section) => `<div class="page-header"><strong>${esc(audit.client.name)}</strong><span>SEO, AEO and GEO audit · ${esc(section)}</span></div>`;
85
+ const footer = (audit, n) => `<div class="page-footer"><span>${esc(audit.client.preparedBy || 'OperStack')} · ${esc(audit.client.reportDate)}</span><span>${n}</span></div>`;
86
+ const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
87
+ const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
88
+ const statusCell = (s) => { const [label, cls] = STATUS[s] || STATUS.na; return `<td class="${cls}">${label}</td>`; };
89
+
90
+ export function toHtml(audit) {
91
+ const a = audit;
92
+ const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['overview', 'Overview']];
93
+ const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
94
+ const scoreCards = Object.entries(a.scores || {}).map(([label, n]) => `<div class="score"><div class="label">${esc(label)}</div><div class="score-row"><span class="score-num ${scoreClass(n)}">${n === null || n === undefined ? 'n/a' : `${n}/10`}</span><span class="score-note">${esc((a.scoreNotes || {})[label] || '')}</span></div></div>`).join('');
95
+ let n = 1;
96
+ const pages = [];
97
+ pages.push(`<div class="page"><div class="cover"><div><div class="cover-top"><div class="eyebrow">Digital marketing audit</div><h1>SEO, AEO and GEO<br>audit report</h1><p class="cover-sub">${t(a.client.subject)}</p><span class="cover-url">${esc(a.meta.host)}</span></div>
98
+ <dl class="cover-meta"><div><dt>Audit subject</dt><dd>${t(a.client.name)}</dd></div><div><dt>Report date</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>Audit type</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>Prepared by</dt><dd>${esc(a.client.preparedBy || 'OperStack')}</dd></div></dl></div>
99
+ <div class="cover-foot">Public signals were collected by ${esc(a.meta.tool)} on ${esc((a.meta.collectedAt || '').slice(0, 10))}. Every status in this report can be reproduced from the site as it stood on that day.</div></div></div>`);
100
+ n++;
101
+ pages.push(`<div class="page">${header(a, 'Executive summary')}<div class="eyebrow">01 · Summary</div><h2>Executive summary</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><div class="verdict"><p><strong>Key takeaway:</strong> ${t(a.summary.verdict)}</p></div><h3>Three priorities</h3>${olist(a.summary.priorities)}${footer(a, n++)}</div>`);
102
+ pages.push(`<div class="page">${header(a, 'Site overview')}<div class="eyebrow">02 · Overview</div><h2>What the site is</h2><table><tr><th>Parameter</th><th>Value</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>Pages sampled</h3><table><tr><th>URL</th><th>Title</th><th>Words</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++)}</div>`);
103
+ const cards = (a.critical || []).map((c, i) => `<div class="card ${c.level === 'warn' ? 'warn' : ''}"><h4>${i + 1}. ${t(c.title)}</h4><p>${t(c.text)}</p></div>`).join('') || '<p>No critical defects were found in the public signals.</p>';
104
+ pages.push(`<div class="page">${header(a, 'Critical issues')}<div class="eyebrow">03 · P0</div><h2>Critical issues, fix first</h2><div class="cards">${cards}</div>${footer(a, n++)}</div>`);
105
+ const techRows = [...checksBy('technical'), ...checksBy('onpage')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('');
106
+ pages.push(`<div class="page">${header(a, 'Technical SEO')}<div class="eyebrow">04 · Technical</div><h2>Technical and on-page checklist</h2><table><tr><th>Check</th><th>Status</th><th>Finding</th></tr>${techRows}</table>${footer(a, n++)}</div>`);
107
+ pages.push(`<div class="page">${header(a, 'Content and on-page')}<div class="eyebrow">05 · Content</div><h2>Content and on-page SEO</h2><div class="two-col"><div><h3>Strengths</h3>${list(a.content.strengths)}</div><div><h3>Weaknesses</h3>${list(a.content.weaknesses)}</div></div><h3>Structural gaps: pages that do not exist yet</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>Check</th><th>Status</th><th>Finding</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++)}</div>`);
108
+ const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment)}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
109
+ pages.push(`<div class="page">${header(a, 'AEO and GEO')}<div class="eyebrow">06 · AEO / GEO</div><h2>Answer engines: AEO</h2><p class="lead">Whether the site's answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.</p><div class="two-col"><div><h3>What already works</h3>${list(a.aeo.works)}</div><div><h3>What blocks the wins</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>Recommendation:</strong> ${t(a.aeo.recommendation)}</p></div><h2>Generative engines: GEO</h2><table><tr><th>Signal</th><th>Status</th><th>Detail</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++)}</div>`);
110
+ pages.push(`<div class="page">${header(a, 'Off-page, conversion, limitations')}<div class="eyebrow">07 · Off-page</div><h2>Off-page and external presence</h2>${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">08 · Conversion</div><h2>Conversion and UX</h2><table><tr><th>Element</th><th>Status</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><div class="eyebrow" style="margin-top:8mm">09 · Limitations</div><h2>Audit limitations</h2><p>This report is based on public data only:</p>${list(a.limitations)}${footer(a, n++)}</div>`);
111
+ const phases = (a.roadmap || []).map((p) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(p.badge)}</span><span class="phase-title">${t(p.title)}</span></div>${olist(p.items)}</div>`).join('');
112
+ pages.push(`<div class="page">${header(a, 'Roadmap')}<div class="eyebrow">10 · Roadmap</div><h2>Work plan: priorities and timeline</h2>${phases}<div class="verdict"><p><strong>Key message:</strong> ${t(a.closing)}</p></div>${footer(a, n++)}</div>`);
113
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: SEO, AEO and GEO audit</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}</style></head><body>${pages.join('\n')}</body></html>`;
114
+ }
115
+
116
+ function findChrome() {
117
+ const candidates = [process.env.CHROME_PATH, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'].filter(Boolean);
118
+ return candidates.find((p) => existsSync(p));
119
+ }
120
+
121
+ export async function render(audit, { out, pdf = false } = {}) {
122
+ const html = toHtml(audit);
123
+ writeFileSync(out, html);
124
+ let pdfPath = null;
125
+ if (pdf) {
126
+ const chrome = findChrome();
127
+ if (!chrome) { console.error('no Chrome found; set CHROME_PATH or open the HTML and print to PDF'); }
128
+ else {
129
+ pdfPath = out.replace(/\.html?$/, '') + '.pdf';
130
+ const profile = mkdtempSync(join(tmpdir(), 'operstack-audit-'));
131
+ const r = spawnSync(chrome, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profile}`, '--virtual-time-budget=8000', '--no-pdf-header-footer', `--print-to-pdf=${pdfPath}`, `file://${out}`], { encoding: 'utf8', timeout: 90000 });
132
+ rmSync(profile, { recursive: true, force: true });
133
+ if (!existsSync(pdfPath)) { console.error(`Chrome did not write the PDF${r.stderr ? `: ${r.stderr.slice(-300)}` : ''}`); pdfPath = null; }
134
+ }
135
+ }
136
+ return { html, pdf: pdfPath };
137
+ }