seo-intel 1.5.46 → 1.5.52

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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * AI Crawler Access — robots.txt analysis for AEO citability.
3
+ *
4
+ * The single biggest AEO failure mode is invisible to on-page scoring: a page
5
+ * can be perfectly structured and still be uncitable because robots.txt blocks
6
+ * the AI assistants' crawlers. This module parses robots.txt and reports which
7
+ * answer-engine / AI crawlers are allowed to read the site.
8
+ *
9
+ * `analyzeAiAccess` is a pure function (robots text → verdict). `fetchAiAccess`
10
+ * is the only thing that touches the network — callers that want to keep the
11
+ * AEO core network-free can fetch robots.txt themselves and pass the text in.
12
+ */
13
+
14
+ // Known AI / answer-engine crawlers. `tier: citation` = used for live grounding
15
+ // and citation in assistant answers (blocking these directly kills citability);
16
+ // `tier: training` = primarily corpus/training crawlers (blocking hurts long-term
17
+ // model familiarity but not live citation as hard).
18
+ export const AI_BOTS = [
19
+ { ua: 'ClaudeBot', vendor: 'Anthropic — Claude', tier: 'citation' },
20
+ { ua: 'Claude-Web', vendor: 'Anthropic — Claude', tier: 'citation' },
21
+ { ua: 'anthropic-ai', vendor: 'Anthropic — Claude', tier: 'citation' },
22
+ { ua: 'GPTBot', vendor: 'OpenAI — ChatGPT', tier: 'citation' },
23
+ { ua: 'OAI-SearchBot', vendor: 'OpenAI — ChatGPT Search', tier: 'citation' },
24
+ { ua: 'ChatGPT-User', vendor: 'OpenAI — ChatGPT (browse)', tier: 'citation' },
25
+ { ua: 'PerplexityBot', vendor: 'Perplexity', tier: 'citation' },
26
+ { ua: 'Perplexity-User', vendor: 'Perplexity (browse)', tier: 'citation' },
27
+ { ua: 'Google-Extended', vendor: 'Google — Gemini / AI Overviews', tier: 'citation' },
28
+ { ua: 'Amazonbot', vendor: 'Amazon — Alexa / Rufus', tier: 'citation' },
29
+ { ua: 'DuckAssistBot', vendor: 'DuckDuckGo — DuckAssist', tier: 'citation' },
30
+ { ua: 'Applebot-Extended', vendor: 'Apple Intelligence', tier: 'training' },
31
+ { ua: 'CCBot', vendor: 'Common Crawl (feeds many LLMs)', tier: 'training' },
32
+ { ua: 'Bytespider', vendor: 'ByteDance — Doubao', tier: 'training' },
33
+ { ua: 'Meta-ExternalAgent', vendor: 'Meta AI', tier: 'training' },
34
+ { ua: 'cohere-ai', vendor: 'Cohere', tier: 'training' },
35
+ ];
36
+
37
+ // ── robots.txt parsing ──────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Parse robots.txt into user-agent groups. A group is one-or-more consecutive
41
+ * `User-agent` lines followed by the rules that apply to them (per RFC 9309: a
42
+ * new User-agent after a rule line starts a fresh group).
43
+ */
44
+ function parseRobots(txt) {
45
+ const groups = [];
46
+ let current = null;
47
+ let lastWasRule = false;
48
+
49
+ for (const raw of txt.split(/\r?\n/)) {
50
+ const line = raw.replace(/#.*$/, '').trim();
51
+ if (!line) continue;
52
+ const idx = line.indexOf(':');
53
+ if (idx === -1) continue;
54
+ const field = line.slice(0, idx).trim().toLowerCase();
55
+ const value = line.slice(idx + 1).trim();
56
+
57
+ if (field === 'user-agent') {
58
+ if (!current || lastWasRule) {
59
+ current = { agents: [], rules: [] };
60
+ groups.push(current);
61
+ lastWasRule = false;
62
+ }
63
+ current.agents.push(value.toLowerCase());
64
+ } else if (field === 'allow' || field === 'disallow') {
65
+ if (!current) { current = { agents: ['*'], rules: [] }; groups.push(current); }
66
+ current.rules.push({ type: field, path: value });
67
+ lastWasRule = true;
68
+ }
69
+ }
70
+ return groups;
71
+ }
72
+
73
+ /** Pick the group that governs `ua`: exact match wins, else the `*` group. */
74
+ function groupFor(groups, ua) {
75
+ const lc = ua.toLowerCase();
76
+ return groups.find(g => g.agents.includes(lc))
77
+ || groups.find(g => g.agents.includes('*'))
78
+ || null;
79
+ }
80
+
81
+ /** Does this group block the site root (`/`)? `Disallow: /` blocks all; an
82
+ * explicit `Allow: /` overrides; empty `Disallow:` means allow-all. */
83
+ function blocksRoot(group) {
84
+ if (!group) return false;
85
+ let blocked = false;
86
+ for (const r of group.rules) {
87
+ if (r.type === 'disallow' && (r.path === '/' || r.path === '/*')) blocked = true;
88
+ else if (r.type === 'allow' && r.path === '/') blocked = false;
89
+ }
90
+ return blocked;
91
+ }
92
+
93
+ // ── Verdict ─────────────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Analyze robots.txt for AI-crawler access. Pure function.
97
+ *
98
+ * @param {string} robotsTxt - raw robots.txt body ('' if none/unavailable)
99
+ * @param {object} [opts] - { fetched: boolean } — false when robots couldn't be read
100
+ * @returns {object} {
101
+ * score, blocked, verdict, blockedBots[], allowedCount, citationBlocked[],
102
+ * aiTrainSignal, fetched, detail
103
+ * }
104
+ */
105
+ export function analyzeAiAccess(robotsTxt, opts = {}) {
106
+ const fetched = opts.fetched ?? true;
107
+
108
+ // No robots.txt (or unreadable) → crawlers default-allow. Open, but flagged.
109
+ if (!fetched || !robotsTxt || !robotsTxt.trim()) {
110
+ return {
111
+ score: 100, blocked: false, verdict: 'open',
112
+ blockedBots: [], citationBlocked: [], allowedCount: AI_BOTS.length,
113
+ aiTrainSignal: null, fetched,
114
+ detail: fetched
115
+ ? 'No robots.txt rules — all AI crawlers default-allowed.'
116
+ : 'robots.txt unavailable — assuming open (crawlers default-allow when absent).',
117
+ };
118
+ }
119
+
120
+ const groups = parseRobots(robotsTxt);
121
+ const aiTrainSignal = /content-signal\s*:[^\n]*\bai-train\s*=\s*no\b/i.test(robotsTxt)
122
+ ? 'ai-train=no' : null;
123
+
124
+ const blockedBots = [];
125
+ for (const bot of AI_BOTS) {
126
+ if (blocksRoot(groupFor(groups, bot.ua))) blockedBots.push(bot);
127
+ }
128
+ const citationBlocked = blockedBots.filter(b => b.tier === 'citation');
129
+
130
+ let penalty = 0;
131
+ for (const b of blockedBots) penalty += b.tier === 'citation' ? 18 : 7;
132
+ if (aiTrainSignal) penalty += 8;
133
+ const score = Math.max(0, 100 - penalty);
134
+
135
+ // `blocked` = the hard-reality gate: any live citation crawler is locked out.
136
+ const blocked = citationBlocked.length > 0;
137
+ let verdict;
138
+ if (citationBlocked.length >= 3 || score < 40) verdict = 'blocked';
139
+ else if (blockedBots.length > 0 || aiTrainSignal) verdict = 'partial';
140
+ else verdict = 'open';
141
+
142
+ const names = citationBlocked.map(b => b.ua);
143
+ const detail = verdict === 'blocked'
144
+ ? `robots.txt blocks ${citationBlocked.length} answer-engine crawler(s) (${names.slice(0, 6).join(', ')}${names.length > 6 ? '…' : ''}) — these pages cannot be cited by the assistants developers actually use.`
145
+ : verdict === 'partial'
146
+ ? `Some AI crawlers blocked${aiTrainSignal ? ' and Content-Signal ai-train=no set' : ''}; live citation still possible but reduced.`
147
+ : 'All major AI crawlers allowed.';
148
+
149
+ return {
150
+ score, blocked, verdict,
151
+ blockedBots: blockedBots.map(b => ({ ua: b.ua, vendor: b.vendor, tier: b.tier })),
152
+ citationBlocked: citationBlocked.map(b => b.ua),
153
+ allowedCount: AI_BOTS.length - blockedBots.length,
154
+ aiTrainSignal, fetched, detail,
155
+ };
156
+ }
157
+
158
+ // ── Network fetch (the only I/O in this module) ─────────────────────────────
159
+
160
+ /**
161
+ * Fetch + analyze robots.txt for a site. Best-effort: any failure degrades to
162
+ * an "assume open" verdict rather than throwing.
163
+ *
164
+ * @param {string} siteUrl - origin or any URL on the site
165
+ * @param {object} [opts] - { timeoutMs }
166
+ */
167
+ export async function fetchAiAccess(siteUrl, opts = {}) {
168
+ const timeoutMs = opts.timeoutMs ?? 5000;
169
+ let origin;
170
+ try {
171
+ origin = new URL(/^https?:\/\//.test(siteUrl) ? siteUrl : `https://${siteUrl}`).origin;
172
+ } catch {
173
+ return { ...analyzeAiAccess('', { fetched: false }), origin: null };
174
+ }
175
+
176
+ try {
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
179
+ const res = await fetch(`${origin}/robots.txt`, {
180
+ signal: controller.signal,
181
+ redirect: 'follow',
182
+ headers: { 'user-agent': 'seo-intel-aeo (+https://ukkometa.fi/seo-intel)' },
183
+ });
184
+ clearTimeout(timer);
185
+ if (!res.ok) {
186
+ return { ...analyzeAiAccess('', { fetched: true }), origin, httpStatus: res.status };
187
+ }
188
+ const txt = await res.text();
189
+ return { ...analyzeAiAccess(txt), origin, httpStatus: res.status };
190
+ } catch (e) {
191
+ return { ...analyzeAiAccess('', { fetched: false }), origin, error: e.message };
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Fetch AI access for many domains in parallel. Returns Map<domain, verdict>.
197
+ * Domains can be bare ("docs.carbium.sh") or full URLs.
198
+ */
199
+ export async function fetchAiAccessForDomains(domains, opts = {}) {
200
+ const map = new Map();
201
+ await Promise.all([...new Set(domains)].map(async (d) => {
202
+ const verdict = await fetchAiAccess(d, opts);
203
+ // key by bare host so it matches the `domains.domain` column
204
+ let host = d;
205
+ try { host = new URL(/^https?:\/\//.test(d) ? d : `https://${d}`).hostname; } catch { /* keep */ }
206
+ map.set(host, verdict);
207
+ map.set(host.replace(/^www\./, ''), verdict);
208
+ }));
209
+ return map;
210
+ }
@@ -12,12 +12,16 @@ import { scorePage } from './scorer.js';
12
12
  *
13
13
  * @param {import('node:sqlite').DatabaseSync} db
14
14
  * @param {string} project
15
- * @param {object} opts - { includeCompetitors: boolean, log: function }
15
+ * @param {object} opts - { includeCompetitors, log, aiAccessByDomain }
16
+ * aiAccessByDomain: optional Map<domain, verdict> from ai-access.js. Pure —
17
+ * this function never touches the network; callers fetch robots.txt and pass
18
+ * the verdicts in (preserves the "AEO runs on existing crawl data" contract).
16
19
  * @returns {object} { target: PageScore[], competitors: Map<domain, PageScore[]>, summary }
17
20
  */
18
21
  export function runAeoAnalysis(db, project, opts = {}) {
19
22
  const log = opts.log || console.log;
20
23
  const includeCompetitors = opts.includeCompetitors ?? true;
24
+ const aiAccessByDomain = opts.aiAccessByDomain || null;
21
25
 
22
26
  // ── Gather pages with body_text ─────────────────────────────────────────
23
27
  const roleFilter = includeCompetitors
@@ -75,8 +79,12 @@ export function runAeoAnalysis(db, project, opts = {}) {
75
79
  entities = JSON.parse(page.primary_entities || '[]');
76
80
  } catch { /* ignore */ }
77
81
 
82
+ const aiAccess = aiAccessByDomain
83
+ ? (aiAccessByDomain.get(page.domain) || aiAccessByDomain.get(page.domain.replace(/^www\./, '')) || null)
84
+ : null;
85
+
78
86
  const result = scorePage(
79
- page, headings, entities, schemaTypes, pageSchemas, page.search_intent
87
+ page, headings, entities, schemaTypes, pageSchemas, page.search_intent, aiAccess
80
88
  );
81
89
 
82
90
  const pageScore = {
@@ -117,6 +125,20 @@ export function runAeoAnalysis(db, project, opts = {}) {
117
125
  const tierCounts = { excellent: 0, good: 0, needs_work: 0, poor: 0 };
118
126
  for (const r of targetResults) tierCounts[r.tier]++;
119
127
 
128
+ // Domain-level AI-access rollup (one verdict per target/owned domain).
129
+ const aiAccess = [];
130
+ if (aiAccessByDomain) {
131
+ const seen = new Set();
132
+ for (const r of targetResults) {
133
+ const key = r.domain.replace(/^www\./, '');
134
+ if (seen.has(key)) continue;
135
+ seen.add(key);
136
+ const v = aiAccessByDomain.get(r.domain) || aiAccessByDomain.get(key);
137
+ if (v) aiAccess.push({ domain: key, verdict: v.verdict, score: v.score, blocked: !!v.blocked, blockedBots: v.citationBlocked || [], detail: v.detail });
138
+ }
139
+ }
140
+ const gatedPages = targetResults.filter(r => r.aiAccessGated).length;
141
+
120
142
  const summary = {
121
143
  totalScored: scored,
122
144
  targetPages: targetResults.length,
@@ -126,6 +148,8 @@ export function runAeoAnalysis(db, project, opts = {}) {
126
148
  scoreDelta: avgTarget - avgComp,
127
149
  tierCounts,
128
150
  weakestSignals: getWeakestSignals(targetResults),
151
+ aiAccess,
152
+ gatedPages,
129
153
  };
130
154
 
131
155
  log(` Scored ${scored} pages (${targetResults.length} target, ${compScores.length} competitor)`);
@@ -171,7 +195,7 @@ export function persistAeoScores(db, results) {
171
195
  /**
172
196
  * Feed low-scoring pages into Intelligence Ledger as citability_gap insights
173
197
  */
174
- export function upsertCitabilityInsights(db, project, targetResults) {
198
+ export function upsertCitabilityInsights(db, project, targetResults, aiAccess = null) {
175
199
  const upsertStmt = db.prepare(`
176
200
  INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
177
201
  VALUES (?, 'citability_gap', 'active', ?, ?, ?, NULL, ?)
@@ -183,6 +207,27 @@ export function upsertCitabilityInsights(db, project, targetResults) {
183
207
  const ts = Date.now();
184
208
  db.exec('BEGIN');
185
209
  try {
210
+ // Domain-level AI-access blocks — the highest-severity citability gap: the
211
+ // page can't be cited at all because robots.txt locks out the crawlers.
212
+ if (Array.isArray(aiAccess)) {
213
+ for (const a of aiAccess) {
214
+ if (a.verdict === 'open') continue;
215
+ const fp = `ai-access::${a.domain}`;
216
+ const data = {
217
+ domain: a.domain,
218
+ score: a.score,
219
+ tier: a.blocked ? 'poor' : 'needs_work',
220
+ verdict: a.verdict,
221
+ blocked_crawlers: a.blockedBots,
222
+ weakest_signals: ['ai access'],
223
+ recommendation: a.blocked
224
+ ? `robots.txt blocks AI answer-engine crawlers (${(a.blockedBots || []).slice(0, 5).join(', ')}). Allow ClaudeBot / GPTBot / PerplexityBot / Google-Extended so the assistants developers use can read and cite ${a.domain}.`
225
+ : `${a.detail} Review robots.txt AI-crawler rules on ${a.domain}.`,
226
+ };
227
+ upsertStmt.run(project, fp, ts, ts, JSON.stringify(data));
228
+ }
229
+ }
230
+
186
231
  for (const r of targetResults) {
187
232
  if (r.score >= 60) continue; // only flag pages that need work
188
233
 
@@ -216,14 +261,12 @@ export function upsertCitabilityInsights(db, project, targetResults) {
216
261
  function getWeakestSignals(targetResults) {
217
262
  if (!targetResults.length) return [];
218
263
 
219
- const signalTotals = {
220
- entity_authority: 0, structured_claims: 0, answer_density: 0,
221
- qa_proximity: 0, freshness: 0, schema_coverage: 0,
222
- };
223
-
264
+ // Key-agnostic: ai_access only appears in the breakdown when robots data was
265
+ // supplied, so build the accumulator from whatever signals are present.
266
+ const signalTotals = {};
224
267
  for (const r of targetResults) {
225
268
  for (const [k, v] of Object.entries(r.breakdown)) {
226
- signalTotals[k] += v;
269
+ signalTotals[k] = (signalTotals[k] || 0) + v;
227
270
  }
228
271
  }
229
272
 
@@ -0,0 +1,59 @@
1
+ // rescore.js — re-check a single URL's AI citability after a fix.
2
+ //
3
+ // Read-only re-measurement (SEO Intel mutates nothing). Uses the lightweight
4
+ // HTTP crawl — the RAW-HTML / "what bots see" lens: if an agent's fix is
5
+ // server-rendered, the score moves; if it's JS-only, it correctly does not.
6
+ // This closes the agent loop: act → re-score → see the delta.
7
+ import { lightCrawl } from '../../crawler/light.js';
8
+
9
+ const bucket = (s) => (s == null ? null : s >= 75 ? 'good' : s >= 55 ? 'needs-work' : s >= 35 ? 'weak' : 'poor');
10
+
11
+ /**
12
+ * @param {object} db open SQLite handle (for the stored baseline)
13
+ * @param {string} project project name (scopes the baseline lookup)
14
+ * @param {string} url the URL to re-score
15
+ * @param {object} [opts] { log?: (msg)=>void }
16
+ * @returns {Promise<{url, status_code, lens, before, after, delta, improved, status_before, status_after, signals, note}>}
17
+ */
18
+ export async function rescorePage(db, project, url, opts = {}) {
19
+ // Baseline = most recent stored citability score (from the last full crawl).
20
+ let before = null;
21
+ try {
22
+ const row = db.prepare(`
23
+ SELECT c.score
24
+ FROM citability_scores c
25
+ JOIN pages p ON p.id = c.page_id
26
+ JOIN domains d ON d.id = p.domain_id
27
+ WHERE d.project = ? AND p.url = ?
28
+ ORDER BY c.scored_at DESC
29
+ LIMIT 1
30
+ `).get(project, url);
31
+ before = row ? row.score : null;
32
+ } catch { /* citability_scores table may not exist yet */ }
33
+
34
+ const r = await lightCrawl(url, {
35
+ maxPages: 1,
36
+ includeCitability: true,
37
+ sameOrigin: true,
38
+ onProgress: opts.log,
39
+ });
40
+
41
+ const page = (r.pages || [])[0] || null;
42
+ const cite = page && page.citability && !page.citability.error ? page.citability : null;
43
+ const after = cite ? cite.score : null;
44
+ const delta = before != null && after != null ? after - before : null;
45
+
46
+ return {
47
+ url,
48
+ status_code: page ? page.status_code : null,
49
+ lens: 'raw-html', // bot's-eye view — what crawlers / LLMs actually see
50
+ before, // last full-crawl score (baseline)
51
+ after, // current raw / bot-visible score
52
+ delta,
53
+ improved: delta != null ? delta > 0 : null,
54
+ status_before: bucket(before),
55
+ status_after: bucket(after),
56
+ signals: cite ? cite.breakdown : null,
57
+ note: cite ? null : ((page && page.citability && page.citability.error) || 'no citability computed for this URL'),
58
+ };
59
+ }
@@ -265,9 +265,12 @@ export function richResultProbability(headings, bodyText, schemaTypes, wordCount
265
265
  * @param {string[]} schemaTypes - schema type strings present on page
266
266
  * @param {object[]} schemas - full page_schemas rows
267
267
  * @param {string} searchIntent - from extraction
268
- * @returns {object} { score, breakdown, aiIntents, tier, richResult }
268
+ * @param {object} [aiAccess] - domain-level robots.txt verdict from ai-access.js
269
+ * ({ score, blocked, verdict }). When provided, adds a 7th signal and applies
270
+ * a hard-reality gate: pages whose AI crawlers are blocked can't be cited.
271
+ * @returns {object} { score, breakdown, aiIntents, tier, richResult, aiAccess, aiAccessGated }
269
272
  */
270
- export function scorePage(page, headings, entities, schemaTypes, schemas, searchIntent) {
273
+ export function scorePage(page, headings, entities, schemaTypes, schemas, searchIntent, aiAccess = null) {
271
274
  const bodyText = page.body_text || '';
272
275
  const wordCount = page.word_count || bodyText.split(/\s+/).length;
273
276
 
@@ -280,20 +283,37 @@ export function scorePage(page, headings, entities, schemaTypes, schemas, search
280
283
  schema_coverage: schemaCoverageScore(schemaTypes),
281
284
  };
282
285
 
283
- // Weighted composite — entity authority and structured claims matter most for AI
284
- const weights = {
285
- entity_authority: 0.25,
286
- structured_claims: 0.20,
287
- answer_density: 0.20,
288
- qa_proximity: 0.15,
289
- freshness: 0.10,
290
- schema_coverage: 0.10,
291
- };
286
+ // Weighted composite — entity authority and structured claims matter most for AI.
287
+ // When a domain-level AI-access verdict is supplied we fold in a 7th signal and
288
+ // rebalance to sum to 1.0; otherwise the original 6-signal model is preserved
289
+ // exactly (callers without robots data are unaffected).
290
+ const hasAiAccess = aiAccess && typeof aiAccess.score === 'number';
291
+ let weights;
292
+ if (hasAiAccess) {
293
+ breakdown.ai_access = aiAccess.score;
294
+ weights = {
295
+ entity_authority: 0.22, structured_claims: 0.18, answer_density: 0.18,
296
+ qa_proximity: 0.14, freshness: 0.08, schema_coverage: 0.10, ai_access: 0.10,
297
+ };
298
+ } else {
299
+ weights = {
300
+ entity_authority: 0.25, structured_claims: 0.20, answer_density: 0.20,
301
+ qa_proximity: 0.15, freshness: 0.10, schema_coverage: 0.10,
302
+ };
303
+ }
292
304
 
293
- const score = Math.round(
305
+ let score = Math.round(
294
306
  Object.entries(weights).reduce((sum, [k, w]) => sum + breakdown[k] * w, 0)
295
307
  );
296
308
 
309
+ // Hard-reality gate: if AI assistants are blocked from fetching the page,
310
+ // on-page quality is moot — they cannot cite what they cannot read.
311
+ let aiAccessGated = false;
312
+ if (aiAccess && aiAccess.blocked) {
313
+ score = Math.min(score, 30);
314
+ aiAccessGated = true;
315
+ }
316
+
297
317
  const aiIntents = classifyAiIntent(headings, bodyText, searchIntent);
298
318
  const richResult = richResultProbability(headings, bodyText, schemaTypes, wordCount);
299
319
 
@@ -304,5 +324,8 @@ export function scorePage(page, headings, entities, schemaTypes, schemas, search
304
324
  else if (score >= 35) tier = 'needs_work';
305
325
  else tier = 'poor';
306
326
 
307
- return { score, breakdown, aiIntents, tier, richResult };
327
+ return {
328
+ score, breakdown, aiIntents, tier, richResult,
329
+ ...(hasAiAccess ? { aiAccess: { score: aiAccess.score, verdict: aiAccess.verdict, blocked: !!aiAccess.blocked }, aiAccessGated } : {}),
330
+ };
308
331
  }