seo-intel 1.5.21 → 1.5.23

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.23 (2026-04-23)
4
+
5
+ ### Technical Audit — extended-data checks
6
+ - New `seo-intel tech-audit <project>` command — runs technical SEO validation off the crawl DB
7
+ - Findings: title length, meta description length, noindex detection (meta + `X-Robots-Tag`), redirect chains, indexable-but-not-in-sitemap, redirect-target cross-reference
8
+ - `--head` pass runs bounded-concurrency HEAD checks against sitemap URLs (flags 3XX / 4XX)
9
+ - Gated under the `extended-data` banner — same tier surface as other audit extensions
10
+
11
+ ### Crawler — new signal capture
12
+ - Captures final URL after redirects (`page.url()`)
13
+ - Walks the Playwright redirect chain and persists it as JSON
14
+ - Reads `X-Robots-Tag` response header (no-index detection now covers meta **and** header)
15
+ - Sitemap URLs discovered during crawl are persisted to a new `sitemap_urls` table
16
+
17
+ ### Schema
18
+ - `pages` table gains `final_url`, `redirect_chain`, `x_robots_tag` (additive `ALTER TABLE`, safe on existing DBs)
19
+ - New `sitemap_urls` table for the HEAD-check inventory pass
20
+
21
+ ### Accumulated since last changelog (1.5.3–1.5.22)
22
+ - LM Studio extraction backend + auto-discovery
23
+ - Scan command auto-resolves `www` when bare domain is unreachable
24
+ - Intelligence modules: intent scores, schema impact, rich-result probability
25
+ - Nav-link detection for external sites + missing-www redirect warning
26
+ - Solo audit prompt rewrite — no more hallucinated competitors
27
+ - Scan/serve/dashboard resilience fixes
28
+
3
29
  ## 1.5.2 (2026-04-11)
4
30
 
5
31
  ### Unified Export
@@ -123,7 +123,7 @@ function answerDensityScore(bodyText, wordCount) {
123
123
  }
124
124
 
125
125
  // ── Q&A proximity ──────────────────────────────────────────────────────────
126
- function qaProximityScore(headings, bodyText) {
126
+ function qaProximityScore(headings, bodyText, schemaTypes) {
127
127
  if (!headings.length || !bodyText) return 0;
128
128
 
129
129
  const questionHeadings = headings.filter(h =>
@@ -138,8 +138,8 @@ function qaProximityScore(headings, bodyText) {
138
138
  const qRatio = questionHeadings.length / headings.filter(h => h.level >= 2).length;
139
139
  score += Math.min(qRatio * 60, 40);
140
140
 
141
- // FAQ schema present? Huge bonus
142
- score += 30;
141
+ // FAQ schema present? Huge bonus — only award if schema actually exists
142
+ if (Array.isArray(schemaTypes) && schemaTypes.includes('FAQPage')) score += 30;
143
143
 
144
144
  // Heading density (one H2/H3 per ~300 words is ideal)
145
145
  const h2h3Count = headings.filter(h => h.level >= 2 && h.level <= 3).length;
@@ -201,6 +201,59 @@ function classifyAiIntent(headings, bodyText, searchIntent) {
201
201
  return intents;
202
202
  }
203
203
 
204
+ // ── Rich Result Probability Predictor ──────────────────────────────────────
205
+
206
+ /**
207
+ * Estimate probability of achieving rich results based on page signals.
208
+ * Returns per-type probability (FAQ, HowTo, Article) and overall best chance.
209
+ *
210
+ * @param {object[]} headings
211
+ * @param {string} bodyText
212
+ * @param {string[]} schemaTypes - current schema on page
213
+ * @param {number} wordCount
214
+ * @returns {object} { faq, howto, article, best: { type, probability } }
215
+ */
216
+ export function richResultProbability(headings, bodyText, schemaTypes, wordCount) {
217
+ const text = (bodyText || '').toLowerCase();
218
+
219
+ // ── FAQ Rich Result ──
220
+ const questionHeadings = headings.filter(h => h.level >= 2 && h.level <= 3 && QUESTION_RE.test(h.text));
221
+ let faq = 0;
222
+ if (schemaTypes.includes('FAQPage')) faq += 45;
223
+ if (questionHeadings.length >= 3) faq += 25;
224
+ else if (questionHeadings.length >= 1) faq += 10;
225
+ if (wordCount >= 500) faq += 10;
226
+ if (wordCount >= 1500) faq += 5;
227
+ const paras = text.split(/\n\s*\n/).filter(p => p.trim()).length;
228
+ if (paras >= 3 && wordCount / paras < 150) faq += 15;
229
+ faq = Math.min(faq, 95);
230
+
231
+ // ── HowTo Rich Result ──
232
+ let howto = 0;
233
+ if (schemaTypes.includes('HowTo')) howto += 45;
234
+ if (IMPL_RE.test(text)) howto += 20;
235
+ const steps = (text.match(/(?:^|\n)\s*(?:\d+[.)]\s|step\s+\d)/gm) || []).length;
236
+ if (steps >= 3) howto += 20;
237
+ else if (steps >= 1) howto += 8;
238
+ if (wordCount >= 300) howto += 10;
239
+ howto = Math.min(howto, 95);
240
+
241
+ // ── Article Rich Result ──
242
+ const articleSchemas = ['Article', 'TechArticle', 'BlogPosting', 'NewsArticle'];
243
+ let article = 0;
244
+ if (schemaTypes.some(t => articleSchemas.includes(t))) article += 35;
245
+ if (wordCount >= 800) article += 20;
246
+ else if (wordCount >= 400) article += 10;
247
+ if (headings.filter(h => h.level === 2).length >= 2) article += 15;
248
+ if (schemaTypes.includes('BreadcrumbList')) article += 10;
249
+ article = Math.min(article, 95);
250
+
251
+ const results = { faq, howto, article };
252
+ const best = Object.entries(results).sort((a, b) => b[1] - a[1])[0];
253
+
254
+ return { ...results, best: { type: best[0], probability: best[1] } };
255
+ }
256
+
204
257
  // ── Main scorer ────────────────────────────────────────────────────────────
205
258
 
206
259
  /**
@@ -212,7 +265,7 @@ function classifyAiIntent(headings, bodyText, searchIntent) {
212
265
  * @param {string[]} schemaTypes - schema type strings present on page
213
266
  * @param {object[]} schemas - full page_schemas rows
214
267
  * @param {string} searchIntent - from extraction
215
- * @returns {object} { score, breakdown, aiIntents, tier }
268
+ * @returns {object} { score, breakdown, aiIntents, tier, richResult }
216
269
  */
217
270
  export function scorePage(page, headings, entities, schemaTypes, schemas, searchIntent) {
218
271
  const bodyText = page.body_text || '';
@@ -222,7 +275,7 @@ export function scorePage(page, headings, entities, schemaTypes, schemas, search
222
275
  entity_authority: entityAuthorityScore(entities, headings, wordCount),
223
276
  structured_claims: structuredClaimsScore(bodyText, headings),
224
277
  answer_density: answerDensityScore(bodyText, wordCount),
225
- qa_proximity: qaProximityScore(headings, bodyText),
278
+ qa_proximity: qaProximityScore(headings, bodyText, schemaTypes),
226
279
  freshness: freshnessScore(page, schemas),
227
280
  schema_coverage: schemaCoverageScore(schemaTypes),
228
281
  };
@@ -242,6 +295,7 @@ export function scorePage(page, headings, entities, schemaTypes, schemas, search
242
295
  );
243
296
 
244
297
  const aiIntents = classifyAiIntent(headings, bodyText, searchIntent);
298
+ const richResult = richResultProbability(headings, bodyText, schemaTypes, wordCount);
245
299
 
246
300
  // Tier classification
247
301
  let tier;
@@ -250,5 +304,5 @@ export function scorePage(page, headings, entities, schemaTypes, schemas, search
250
304
  else if (score >= 35) tier = 'needs_work';
251
305
  else tier = 'poor';
252
306
 
253
- return { score, breakdown, aiIntents, tier };
307
+ return { score, breakdown, aiIntents, tier, richResult };
254
308
  }
@@ -44,7 +44,7 @@ export async function runTemplatesAnalysis(project, opts = {}) {
44
44
  if (!config) throw new Error(`Project "${project}" not found. Run: seo-intel setup`);
45
45
 
46
46
  const targetDomain = config.target.domain;
47
- const targetUrl = config.target.url || `https://${targetDomain}`;
47
+ const targetUrl = (config.target.url || `https://${targetDomain}`).replace(/\/+$/, '');
48
48
 
49
49
  log(`\n Target: ${targetDomain}`);
50
50
 
@@ -25,6 +25,171 @@
25
25
  * @param {object} params.context - project context (industry, audience, goals)
26
26
  */
27
27
  export function buildAnalysisPrompt({ project, target, competitors, keywordMatrix, headingStructure, context }) {
28
+ const isSolo = !competitors || competitors.length === 0;
29
+ return isSolo
30
+ ? buildSoloPrompt({ target, keywordMatrix, headingStructure, context })
31
+ : buildCompetitivePrompt({ target, competitors, keywordMatrix, headingStructure, context });
32
+ }
33
+
34
+ // ── Solo audit prompt (no competitors) ─────────────────────────────────────
35
+
36
+ function buildSoloPrompt({ target, keywordMatrix, headingStructure, context }) {
37
+ return `
38
+ # SEO Site Audit — ${context.siteName}
39
+
40
+ You are an expert SEO strategist performing a solo site audit. You have ONLY the crawled site data below — no competitor data.
41
+
42
+ **CRITICAL RULES:**
43
+ - You have ZERO competitor data. Do NOT invent, hallucinate, or reference any competitor domains.
44
+ - Never fill "covered_by" with domain names you were not given.
45
+ - Base keyword and content recommendations on: (1) the crawled site data, (2) your knowledge of the "${context.industry}" industry and what audiences search for.
46
+ - Label all industry-knowledge suggestions as "industry research" — not "data-driven".
47
+ - Every URL slug you suggest must be a real path (e.g. "/blog/how-to-x"), never "/undefined".
48
+
49
+ ---
50
+
51
+ ## CONTEXT
52
+
53
+ **Site:** ${context.siteName} (${context.url})
54
+ **Industry:** ${context.industry}
55
+ **Target audience:** ${context.audience}
56
+ **Business goal:** ${context.goal}
57
+ **Current SEO maturity:** ${context.maturity || 'early stage'}
58
+
59
+ ### Site Architecture
60
+ ${context.site_architecture ? `
61
+ ${context.site_architecture.note}
62
+
63
+ Available publishing properties:
64
+ ${context.site_architecture.properties.map(p =>
65
+ `- **${p.id}** (${p.url}, platform: ${p.platform})\n Best for: ${p.best_for}\n Difficulty: ${p.difficulty}${p.seo_note ? `\n SEO note: ${p.seo_note}` : ''}`
66
+ ).join('\n')}
67
+ ` : 'No site architecture configured — recommend generic URL slugs.'}
68
+
69
+ ---
70
+
71
+ ## SITE DATA
72
+
73
+ ${formatSiteSummary(target)}
74
+
75
+ ### Pages crawled: ${target.page_count || target.pageCount || 0}
76
+ ### Keyword coverage:
77
+ ${formatKeywordTable(keywordMatrix, target.domain)}
78
+
79
+ ### Heading structure:
80
+ ${formatHeadings(headingStructure, target.domain)}
81
+
82
+ ---
83
+
84
+ ## ANALYSIS TASKS
85
+
86
+ ### 1. KEYWORD OPPORTUNITIES
87
+ - Based on the site's existing content and the "${context.industry}" industry, identify 5-10 keyword phrases the site should target
88
+ - For each: search intent, estimated search demand (low/medium/high), difficulty, and whether to add to an existing page or create a new one
89
+ - Focus on keywords that match the site's actual product/service — no speculative gaps
90
+
91
+ ### 2. LONG-TAIL OPPORTUNITIES
92
+ - Generate 10-20 specific long-tail phrases (3-6 words) from the site's content themes
93
+ - Focus on: question queries, feature queries, use-case queries
94
+ - For each: intent, page type, priority
95
+ - Weight toward commercial intent
96
+
97
+ ### 3. CONTENT EXPANSION
98
+ - Topic areas the site should cover based on industry norms and audience needs
99
+ - Do NOT reference competitor domains — use "industry standard" or "common in ${context.industry}" instead
100
+ - For each: why it matters for this audience, suggested format, suggested title
101
+
102
+ ### 4. QUICK WINS (existing pages to improve)
103
+ - Pages with thin content, missing structure, or weak metadata
104
+ - Only reference pages that appear in the crawled data above
105
+ - For each: specific fix, estimated impact
106
+
107
+ ### 5. NEW PAGE SUGGESTIONS
108
+ - Specific new pages to create based on keyword opportunities
109
+ - For each: URL slug (real path like /blog/topic), title, target keyword, content angle
110
+
111
+ ### 6. TECHNICAL SEO AUDIT
112
+ - Schema markup opportunities (FAQ, HowTo, Product, etc.)
113
+ - Meta description quality assessment
114
+ - H1/heading structure recommendations
115
+ - Do NOT compare to competitors — assess against SEO best practices
116
+
117
+ ### 7. MARKET POSITIONING
118
+ - Based on the site's content and industry, what positioning should this site own?
119
+ - What audience need is underserved in this space?
120
+ - What is the site's clearest differentiator from its current content?
121
+
122
+ ---
123
+
124
+ ## OUTPUT SCHEMA
125
+
126
+ Respond ONLY with valid JSON in this exact structure:
127
+
128
+ {
129
+ "keyword_gaps": [
130
+ {
131
+ "keyword": "string — 2-4 word SEO phrase",
132
+ "intent": "informational|commercial|navigational|transactional",
133
+ "search_demand": "low|medium|high",
134
+ "difficulty": "low|medium|high",
135
+ "suggested_action": "add_to_existing|new_page",
136
+ "suggested_page": "string — URL path like /blog/topic or existing page URL",
137
+ "priority": "high|medium|low",
138
+ "source": "site_content|industry_research"
139
+ }
140
+ ],
141
+ "long_tails": [
142
+ {
143
+ "phrase": "string",
144
+ "intent": "string",
145
+ "page_type": "blog|landing|doc|faq|comparison|glossary",
146
+ "priority": "high|medium|low",
147
+ "notes": "string"
148
+ }
149
+ ],
150
+ "content_gaps": [
151
+ {
152
+ "topic": "string",
153
+ "why_it_matters": "string",
154
+ "format": "blog|comparison|use_case|glossary|how_to|landing",
155
+ "suggested_title": "string"
156
+ }
157
+ ],
158
+ "quick_wins": [
159
+ {
160
+ "page": "string — URL from crawled data",
161
+ "issue": "string",
162
+ "fix": "string",
163
+ "impact": "high|medium|low"
164
+ }
165
+ ],
166
+ "new_pages": [
167
+ {
168
+ "title": "string",
169
+ "target_keyword": "string",
170
+ "content_angle": "string",
171
+ "why": "string",
172
+ "priority": "high|medium|low"
173
+ }
174
+ ],
175
+ "technical_gaps": [
176
+ {
177
+ "gap": "string",
178
+ "fix": "string"
179
+ }
180
+ ],
181
+ "positioning": {
182
+ "market_context": "string — 2-3 sentences on the industry landscape",
183
+ "open_angle": "string — what positioning this site should own",
184
+ "target_differentiator": "string — clearest differentiator from current content"
185
+ }
186
+ }
187
+ `.trim();
188
+ }
189
+
190
+ // ── Competitive prompt (with competitors) ──────────────────────────────────
191
+
192
+ function buildCompetitivePrompt({ target, competitors, keywordMatrix, headingStructure, context }) {
28
193
  return `
29
194
  # SEO Competitive Intelligence Analysis — ${context.siteName}
30
195
 
@@ -59,7 +224,7 @@ For each content recommendation, rank ALL available properties as placement opti
59
224
 
60
225
  ${formatSiteSummary(target)}
61
226
 
62
- ### Pages crawled: ${target.pageCount}
227
+ ### Pages crawled: ${target.page_count || target.pageCount || 0}
63
228
  ### Keyword coverage:
64
229
  ${formatKeywordTable(keywordMatrix, target.domain)}
65
230
 
@@ -218,7 +383,7 @@ Respond ONLY with valid JSON in this exact structure:
218
383
  function formatSiteSummary(site) {
219
384
  return `
220
385
  - Domain: ${site.domain}
221
- - Pages crawled: ${site.pageCount || 0}
386
+ - Pages crawled: ${site.page_count || site.pageCount || 0}
222
387
  - Avg word count: ${Math.round(site.avg_word_count || 0)}
223
388
  - Product types detected: ${site.product_types || 'unknown'}
224
389
  - Pricing model: ${site.pricing_tiers || 'unknown'}
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Technical SEO Audit — reads crawl data from the DB and produces findings.
3
+ *
4
+ * Extended-data checks (gated via lib/gate.js `extended-data`):
5
+ * 1. Title length (>60 warn, missing err)
6
+ * 2. Meta description length (>160 warn, >320 err, missing err)
7
+ * 3. Noindex detection (meta robots OR X-Robots-Tag header)
8
+ * 4. Indexable pages missing from sitemap (set diff)
9
+ * 5. Redirect chain surfacing (uses final_url + redirect_chain columns)
10
+ * 6. Canonical points to a redirect target (uses redirect_chain + technical)
11
+ *
12
+ * Additional optional pass (network-heavy, must be explicitly enabled):
13
+ * - Sitemap HEAD check: flags 3XX / 4XX URLs in the sitemap itself.
14
+ */
15
+
16
+ import { gateSection } from '../lib/gate.js';
17
+ import { headCheckAll } from '../crawler/sitemap.js';
18
+ import {
19
+ getSitemapUrlsForDomain,
20
+ updateSitemapHeadResult,
21
+ } from '../db/db.js';
22
+
23
+ const TITLE_WARN = 60;
24
+ const DESC_WARN = 160;
25
+ const DESC_ERR = 320;
26
+
27
+ /**
28
+ * Run the audit for a single domain. Returns { findings: [], stats: {} }.
29
+ * Pass { runSitemapHead: true } to run the HEAD pass over the sitemap inventory.
30
+ * Gated: the actual checks only run when the `extended-data` gate is open.
31
+ */
32
+ export async function runTechnicalAudit(db, { project, domain, runSitemapHead = false, sitemapConcurrency = 6 } = {}) {
33
+ if (!gateSection('extended-data')) {
34
+ return { gated: true, findings: [], stats: {} };
35
+ }
36
+
37
+ const domainRow = db.prepare(
38
+ 'SELECT id, domain FROM domains WHERE domain = ? AND project = ?'
39
+ ).get(domain, project);
40
+ if (!domainRow) {
41
+ return { gated: false, findings: [], stats: {}, error: `domain not found: ${domain}` };
42
+ }
43
+ const domainId = domainRow.id;
44
+
45
+ const findings = [];
46
+
47
+ // ── Page-level checks (read from pages + technical) ──
48
+ const pages = db.prepare(`
49
+ SELECT
50
+ p.id, p.url, p.final_url, p.redirect_chain, p.x_robots_tag,
51
+ p.is_indexable, p.status_code, p.title, p.meta_desc,
52
+ t.has_canonical
53
+ FROM pages p
54
+ LEFT JOIN technical t ON t.page_id = p.id
55
+ WHERE p.domain_id = ?
56
+ `).all(domainId);
57
+
58
+ const redirectTargets = new Set();
59
+
60
+ for (const p of pages) {
61
+ // 1. Title length
62
+ if (!p.title) {
63
+ findings.push({ type: 'title_missing', severity: 'error', url: p.url, details: 'No <title>' });
64
+ } else if (p.title.length > TITLE_WARN) {
65
+ findings.push({ type: 'title_too_long', severity: 'warn', url: p.url, details: `${p.title.length}/${TITLE_WARN}` });
66
+ }
67
+
68
+ // 2. Meta description length
69
+ if (!p.meta_desc) {
70
+ findings.push({ type: 'meta_desc_missing', severity: 'error', url: p.url, details: 'No meta description' });
71
+ } else if (p.meta_desc.length > DESC_ERR) {
72
+ findings.push({ type: 'meta_desc_too_long', severity: 'error', url: p.url, details: `${p.meta_desc.length}/${DESC_ERR}` });
73
+ } else if (p.meta_desc.length > DESC_WARN) {
74
+ findings.push({ type: 'meta_desc_too_long', severity: 'warn', url: p.url, details: `${p.meta_desc.length}/${DESC_WARN}` });
75
+ }
76
+
77
+ // 3. Noindex (meta OR X-Robots-Tag) — informational only (valid decision, not error)
78
+ const xrt = (p.x_robots_tag || '').toLowerCase();
79
+ if (xrt.includes('noindex') && p.is_indexable === 0) {
80
+ findings.push({ type: 'noindex_header', severity: 'info', url: p.url, details: `X-Robots-Tag: ${p.x_robots_tag}` });
81
+ }
82
+
83
+ // 5. Redirect chain
84
+ let chain = [];
85
+ try { chain = p.redirect_chain ? JSON.parse(p.redirect_chain) : []; } catch { chain = []; }
86
+ if (chain.length > 0) {
87
+ const finalUrl = p.final_url || p.url;
88
+ findings.push({
89
+ type: 'redirect_chain',
90
+ severity: chain.length >= 2 ? 'warn' : 'info',
91
+ url: p.url,
92
+ details: `${chain.length} hop(s) → ${finalUrl}`,
93
+ hops: chain,
94
+ finalUrl,
95
+ });
96
+ redirectTargets.add(finalUrl);
97
+ }
98
+ }
99
+
100
+ // 6. Canonical-points-to-redirect — requires a second pass with canonical URLs.
101
+ // `technical.has_canonical` is a boolean; the canonical URL itself isn't stored.
102
+ // For now we surface the set of redirect *targets* so reviewers can cross-reference.
103
+ if (redirectTargets.size > 0) {
104
+ findings.push({
105
+ type: 'redirect_targets_summary',
106
+ severity: 'info',
107
+ details: `${redirectTargets.size} redirect target URL(s) — review canonical tags pointing to any of these`,
108
+ urls: [...redirectTargets],
109
+ });
110
+ }
111
+
112
+ // 4. Indexable-but-not-in-sitemap (set diff)
113
+ const sitemapRows = getSitemapUrlsForDomain(db, domainId);
114
+ const sitemapSet = new Set(sitemapRows.map(r => r.url));
115
+ const missing = pages.filter(p =>
116
+ p.is_indexable === 1 &&
117
+ p.status_code === 200 &&
118
+ !sitemapSet.has(p.url) &&
119
+ !sitemapSet.has(p.final_url || '')
120
+ );
121
+ for (const m of missing) {
122
+ findings.push({
123
+ type: 'indexable_missing_from_sitemap',
124
+ severity: 'warn',
125
+ url: m.url,
126
+ details: 'Page is indexable (200) but not declared in sitemap',
127
+ });
128
+ }
129
+
130
+ // Optional: run HEAD pass over sitemap inventory
131
+ let sitemapHeadStats = null;
132
+ if (runSitemapHead && sitemapRows.length > 0) {
133
+ const uncheckedRows = sitemapRows.filter(r => r.head_checked_at === null);
134
+ const rowsToCheck = uncheckedRows.length ? uncheckedRows : sitemapRows;
135
+ let ok = 0, redirected = 0, broken = 0, errored = 0;
136
+ await headCheckAll(rowsToCheck, {
137
+ concurrency: sitemapConcurrency,
138
+ onResult: (row, res) => {
139
+ updateSitemapHeadResult(db, row.id, res);
140
+ if (!res.status) errored++;
141
+ else if (res.status >= 200 && res.status < 300) ok++;
142
+ else if (res.status >= 300 && res.status < 400) {
143
+ redirected++;
144
+ findings.push({
145
+ type: 'sitemap_redirect',
146
+ severity: 'warn',
147
+ url: row.url,
148
+ details: `Sitemap URL returns ${res.status}${res.location ? ` → ${res.location}` : ''}`,
149
+ });
150
+ }
151
+ else if (res.status >= 400) {
152
+ broken++;
153
+ findings.push({
154
+ type: 'sitemap_broken',
155
+ severity: 'error',
156
+ url: row.url,
157
+ details: `Sitemap URL returns ${res.status}`,
158
+ });
159
+ }
160
+ },
161
+ });
162
+ sitemapHeadStats = { checked: rowsToCheck.length, ok, redirected, broken, errored };
163
+ }
164
+
165
+ const stats = {
166
+ pages: pages.length,
167
+ sitemap_urls: sitemapRows.length,
168
+ findings_total: findings.length,
169
+ findings_by_severity: findings.reduce((acc, f) => {
170
+ acc[f.severity] = (acc[f.severity] || 0) + 1;
171
+ return acc;
172
+ }, {}),
173
+ sitemap_head: sitemapHeadStats,
174
+ };
175
+
176
+ return { gated: false, findings, stats };
177
+ }