@xyleapp/cli 0.7.0 → 0.10.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/README.md CHANGED
@@ -34,6 +34,18 @@ xyle rewrite --url https://example.com/blog/seo-guide --type title
34
34
  # Crawl a page
35
35
  xyle crawl --url https://example.com/blog/seo-guide
36
36
 
37
+ # Full-site crawl (BFS every internal page, detect site-wide issues)
38
+ xyle site-crawl https://example.com --max-pages 500
39
+
40
+ # View snapshot history
41
+ xyle history --url https://example.com
42
+
43
+ # View score trends
44
+ xyle trends --site example.com --days 30
45
+
46
+ # Compare two snapshots
47
+ xyle diff --before <snapshot-id> --after <snapshot-id>
48
+
37
49
  # Sync Search Console data
38
50
  xyle sync --site https://example.com
39
51
  ```
@@ -50,6 +62,10 @@ xyle sync --site https://example.com
50
62
  | `analyze` | Analyze page content against competitors |
51
63
  | `rewrite` | Get AI rewrite suggestions |
52
64
  | `crawl` | Crawl a URL and extract SEO metadata |
65
+ | `site-crawl` | Full-site BFS crawl with issue detection, link graph, and site health score |
66
+ | `history` | View snapshot history for a URL or site |
67
+ | `trends` | View score trends over time |
68
+ | `diff` | Compare two snapshots side-by-side |
53
69
  | `sync` | Sync Google Search Console data |
54
70
  | `login` | Authenticate with Google OAuth |
55
71
  | `logout` | Remove stored credentials |
package/bin/xyle.mjs CHANGED
@@ -7,8 +7,8 @@ const program = new Command();
7
7
 
8
8
  program
9
9
  .name("xyle")
10
- .description("SEO Intelligence Engine CLI")
11
- .version("0.7.0");
10
+ .description("SEO & AEO Intelligence Engine CLI")
11
+ .version("0.10.0");
12
12
 
13
13
  registerCommands(program);
14
14
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xyleapp/cli",
3
- "version": "0.7.0",
4
- "description": "CLI for the Xyle SEO Intelligence Engine",
3
+ "version": "0.10.0",
4
+ "description": "CLI for the Xyle SEO & AEO Intelligence Engine",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "xyle": "bin/xyle.mjs"
package/src/api.mjs CHANGED
@@ -49,7 +49,12 @@ async function request(method, path, { params, body, timeout = 30000, auth = tru
49
49
  let detail;
50
50
  try {
51
51
  const json = await resp.json();
52
- detail = json.detail || resp.statusText;
52
+ const raw = json.detail;
53
+ if (Array.isArray(raw)) {
54
+ detail = raw.map((e) => e.msg || JSON.stringify(e)).join("; ");
55
+ } else {
56
+ detail = raw || resp.statusText;
57
+ }
53
58
  } catch {
54
59
  detail = resp.statusText;
55
60
  }
@@ -65,7 +70,9 @@ export function checkHealth() {
65
70
  }
66
71
 
67
72
  export function getTopQueries(site, limit = 20) {
68
- return request("GET", "/queries", { params: { site, limit } });
73
+ const creds = getCredentials();
74
+ const email = creds?.email || null;
75
+ return request("GET", "/queries", { params: { site, limit, email } });
69
76
  }
70
77
 
71
78
  export function getCompetitors(query) {
@@ -93,7 +100,9 @@ export function crawlPage(url) {
93
100
  }
94
101
 
95
102
  export function syncGsc(site) {
96
- return request("POST", "/admin/sync", { params: { site } });
103
+ const creds = getCredentials();
104
+ const email = creds?.email || null;
105
+ return request("POST", "/admin/sync", { params: { site, email } });
97
106
  }
98
107
 
99
108
  export function listSites() {
@@ -117,4 +126,64 @@ export function getInstructions(tool) {
117
126
  return request("GET", "/seed/instructions", { params: { tool }, timeout: 10000 });
118
127
  }
119
128
 
129
+ export function listSnapshots(url, siteDomain, limit = 20) {
130
+ return request("GET", "/snapshots", { params: { url, site_domain: siteDomain, limit } });
131
+ }
132
+
133
+ export function getSnapshotTrends(url, siteDomain, days = 90) {
134
+ return request("GET", "/snapshots/trends", { params: { url, site_domain: siteDomain, days } });
135
+ }
136
+
137
+ export function diffSnapshots(beforeId, afterId) {
138
+ return request("GET", "/snapshots/diff", { params: { before: beforeId, after: afterId } });
139
+ }
140
+
141
+ export function askKnowledgeBase(question, url, topic, nSources = 5) {
142
+ const body = { question, n_sources: nSources };
143
+ if (url) body.context_url = url;
144
+ if (topic) body.topic = topic;
145
+ return request("POST", "/kb/ask", { body, timeout: 60000 });
146
+ }
147
+
148
+ export function getKbStats() {
149
+ return request("GET", "/kb/stats", { timeout: 10000 });
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Site Crawl
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export function startSiteCrawl(seedUrl, config = {}) {
157
+ // immediate=1 tells the API to run the crawl in-process via BackgroundTask.
158
+ // The CLI keeps polling /status, which keeps the Cloud Run instance warm
159
+ // for the duration. The web UI uses Trigger.dev instead and omits this.
160
+ return request("POST", "/site-crawl", {
161
+ params: { immediate: 1 },
162
+ body: { seed_url: seedUrl, config },
163
+ timeout: 30000,
164
+ });
165
+ }
166
+
167
+ export function getSiteCrawlStatus(jobId) {
168
+ return request("GET", `/site-crawl/${jobId}`, { timeout: 15000 });
169
+ }
170
+
171
+ export function getSiteCrawlPages(jobId, { limit = 50, offset = 0, filter } = {}) {
172
+ return request("GET", `/site-crawl/${jobId}/pages`, {
173
+ params: { limit, offset, filter },
174
+ timeout: 15000,
175
+ });
176
+ }
177
+
178
+ export function getSiteCrawlIssues(jobId, { severity, category, limit = 200 } = {}) {
179
+ return request("GET", `/site-crawl/${jobId}/issues`, {
180
+ params: { severity, category, limit },
181
+ timeout: 15000,
182
+ });
183
+ }
184
+
185
+ export function listSiteCrawls(limit = 25) {
186
+ return request("GET", "/site-crawl", { params: { limit }, timeout: 15000 });
187
+ }
188
+
120
189
  export { SEO_BASE };
package/src/commands.mjs CHANGED
@@ -3,10 +3,6 @@
3
3
  * Mirrors the Python CLI 1:1.
4
4
  */
5
5
 
6
- import { createRequire } from "node:module";
7
- import { existsSync } from "node:fs";
8
- import { resolve } from "node:path";
9
- import { execSync } from "node:child_process";
10
6
  import { printJson, printTable } from "./formatting.mjs";
11
7
  import {
12
8
  checkHealth,
@@ -18,6 +14,16 @@ import {
18
14
  crawlPage,
19
15
  syncGsc,
20
16
  listSites,
17
+ listSnapshots,
18
+ getSnapshotTrends,
19
+ diffSnapshots,
20
+ askKnowledgeBase,
21
+ getKbStats,
22
+ startSiteCrawl,
23
+ getSiteCrawlStatus,
24
+ getSiteCrawlPages,
25
+ getSiteCrawlIssues,
26
+ listSiteCrawls,
21
27
  SEO_BASE,
22
28
  } from "./api.mjs";
23
29
  import { getCredentials, clearCredentials, runLoginFlow } from "./auth.mjs";
@@ -136,24 +142,67 @@ export function registerCommands(program) {
136
142
  if (opts.json) {
137
143
  console.log(printJson(data));
138
144
  } else {
139
- const score = data.score || 0;
140
- const color = score >= 0.7 ? "\x1b[32m" : "\x1b[33m";
141
- console.log(`${color}SEO Score: ${Math.round(score * 100)}%\x1b[0m`);
142
- console.log(`Summary: ${data.summary || ""}`);
145
+ const scoreBar = (label, score) => {
146
+ const pct = Math.round((score || 0) * 100);
147
+ const filled = Math.round(pct / 5);
148
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
149
+ const color = pct >= 70 ? "\x1b[32m" : pct >= 40 ? "\x1b[33m" : "\x1b[31m";
150
+ return ` ${label.padEnd(22)} ${color}${bar} ${pct}%\x1b[0m`;
151
+ };
152
+
153
+ // SEO Score Breakdown
154
+ const bd = data.seo_breakdown;
155
+ if (bd) {
156
+ console.log(`\n\x1b[1mSEO Score Breakdown\x1b[0m`);
157
+ console.log(scoreBar("Overall", bd.overall));
158
+ console.log(scoreBar("Technical (25%)", bd.technical));
159
+ console.log(scoreBar("On-Page (30%)", bd.on_page));
160
+ console.log(scoreBar("Content (25%)", bd.content));
161
+ console.log(scoreBar("Links (20%)", bd.links));
162
+ } else {
163
+ const score = data.score || 0;
164
+ const color = score >= 0.7 ? "\x1b[32m" : "\x1b[33m";
165
+ console.log(`${color}SEO Score: ${Math.round(score * 100)}%\x1b[0m`);
166
+ }
167
+
168
+ console.log(`\nSummary: ${data.summary || ""}`);
143
169
  const missing = data.missing_topics || [];
144
170
  if (missing.length) {
145
171
  console.log(`Missing topics: ${missing.join(", ")}`);
146
172
  }
147
- // AEO Score + Recommendations
173
+
174
+ // AEO Score
148
175
  if (data.aeo_score != null) {
149
176
  const aeoColor = data.aeo_score >= 0.7 ? "\x1b[32m" : "\x1b[33m";
150
177
  console.log(`${aeoColor}AEO Score: ${Math.round(data.aeo_score * 100)}%\x1b[0m`);
151
178
  }
152
- const recs = data.aeo_recommendations || [];
153
- if (recs.length) {
154
- console.log("AEO Recommendations:");
155
- for (const rec of recs) {
156
- console.log(` \x1b[36m\u2192\x1b[0m ${rec}`);
179
+
180
+ // GEO Score
181
+ if (data.geo_score != null) {
182
+ const geoColor = data.geo_score >= 0.7 ? "\x1b[32m" : "\x1b[33m";
183
+ console.log(`${geoColor}GEO Score: ${Math.round(data.geo_score * 100)}%\x1b[0m`);
184
+ }
185
+
186
+ // Structured Recommendations
187
+ const structured = data.recommendations || [];
188
+ if (structured.length) {
189
+ console.log(`\n\x1b[1mRecommendations\x1b[0m`);
190
+ const priorityColors = { critical: "\x1b[31m", important: "\x1b[33m", nice_to_have: "\x1b[2m" };
191
+ const priorityLabels = { critical: "CRITICAL", important: "IMPORTANT", nice_to_have: "NICE" };
192
+ for (const rec of structured) {
193
+ const pc = priorityColors[rec.priority] || "\x1b[0m";
194
+ const pl = priorityLabels[rec.priority] || rec.priority;
195
+ console.log(` ${pc}[${pl}]\x1b[0m \x1b[36m${rec.category}\x1b[0m ${rec.title}`);
196
+ console.log(` \x1b[2m${rec.description}\x1b[0m`);
197
+ }
198
+ } else {
199
+ // Fallback to old AEO recommendations
200
+ const recs = data.aeo_recommendations || [];
201
+ if (recs.length) {
202
+ console.log("\nAEO Recommendations:");
203
+ for (const rec of recs) {
204
+ console.log(` \x1b[36m\u2192\x1b[0m ${rec}`);
205
+ }
157
206
  }
158
207
  }
159
208
  }
@@ -199,6 +248,16 @@ export function registerCommands(program) {
199
248
  if (opts.json) {
200
249
  console.log(printJson(data));
201
250
  } else {
251
+ const check = (v) => (v ? "\x1b[32m\u2713\x1b[0m" : "\x1b[31m\u2717\x1b[0m");
252
+ const scoreBar = (label, score) => {
253
+ const pct = Math.round((score || 0) * 100);
254
+ const filled = Math.round(pct / 5);
255
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
256
+ const color = pct >= 70 ? "\x1b[32m" : pct >= 40 ? "\x1b[33m" : "\x1b[31m";
257
+ return ` ${label.padEnd(22)} ${color}${bar} ${pct}%\x1b[0m`;
258
+ };
259
+
260
+ console.log(`\n\x1b[1mCrawl Results\x1b[0m`);
202
261
  console.log(`Title: ${data.title || "-"}`);
203
262
  console.log(`Meta desc: ${data.meta_desc || "-"}`);
204
263
  console.log(`Word count: ${data.word_count || 0}`);
@@ -209,11 +268,79 @@ export function registerCommands(program) {
209
268
  console.log(` ${h}`);
210
269
  }
211
270
  }
271
+
272
+ // Technical SEO
273
+ const tech = data.technical_seo;
274
+ if (tech) {
275
+ console.log(`\n\x1b[1mTechnical SEO\x1b[0m`);
276
+ console.log(
277
+ ` ${check(tech.is_https)} HTTPS ${check(tech.has_canonical)} Canonical ${check(tech.has_viewport)} Viewport`
278
+ );
279
+ console.log(
280
+ ` ${check(tech.has_charset)} Charset ${check(tech.has_lang)} Lang (${tech.lang_value || "-"}) ${check(tech.has_favicon)} Favicon`
281
+ );
282
+ console.log(
283
+ ` ${check(tech.has_og_title)} OG Title ${check(tech.has_og_desc)} OG Description ${check(tech.has_og_image)} OG Image`
284
+ );
285
+ console.log(
286
+ ` ${check(tech.has_twitter_card)} Twitter Card ${check(tech.has_robots_meta)} Robots Meta ${check(tech.h1_count === 1)} H1 Count: ${tech.h1_count}`
287
+ );
288
+ console.log(` Title: ${tech.title_length} chars Meta desc: ${tech.meta_desc_length} chars Images without alt: ${tech.images_without_alt}/${tech.image_count}`);
289
+ }
290
+
291
+ // Rendering
292
+ const rendering = data.rendering;
293
+ if (rendering) {
294
+ console.log(`\n\x1b[1mRendering\x1b[0m`);
295
+ const fwLabel = rendering.detected_framework ? `\x1b[35m${rendering.detected_framework}\x1b[0m` : "none";
296
+ const rtColor = rendering.seo_impact === "good" ? "\x1b[32m" : rendering.seo_impact === "poor" ? "\x1b[31m" : "\x1b[33m";
297
+ console.log(` Framework: ${fwLabel} Type: ${rtColor}${rendering.rendering_type.toUpperCase()}\x1b[0m SEO Impact: ${rtColor}${rendering.seo_impact}\x1b[0m`);
298
+ console.log(` JS Bundles: ${rendering.js_bundle_count} DOM Content: ${rendering.initial_dom_has_content ? "rich" : "thin"} Client Router: ${rendering.uses_client_router ? "yes" : "no"}`);
299
+ if (rendering.rendering_notes) {
300
+ console.log(` \x1b[2m${rendering.rendering_notes}\x1b[0m`);
301
+ }
302
+ }
303
+
304
+ // Content Quality
305
+ const cq = data.content_quality;
306
+ if (cq) {
307
+ console.log(`\n\x1b[1mContent Quality\x1b[0m`);
308
+ console.log(` Words: ${cq.word_count} Sentences: ${cq.sentence_count} Paragraphs: ${cq.paragraph_count} Reading time: ${cq.reading_time_minutes} min`);
309
+ console.log(` Flesch Reading Ease: ${cq.flesch_reading_ease} Grade Level: ${cq.flesch_kincaid_grade} Vocabulary: ${Math.round(cq.vocabulary_richness * 100)}%`);
310
+ console.log(scoreBar("Humanness Score", cq.humanness_score));
311
+ console.log(scoreBar("Content Depth", cq.content_depth_score));
312
+ console.log(scoreBar("E-E-A-T Score", cq.eeat_score));
313
+ console.log(` ${check(cq.has_author)} Author ${check(cq.has_publish_date)} Publish Date ${check(cq.has_updated_date)} Updated Date ${check(cq.has_sources_section)} Sources`);
314
+ }
315
+
316
+ // Link Analysis
317
+ const links = data.link_analysis;
318
+ if (links) {
319
+ console.log(`\n\x1b[1mLink Analysis\x1b[0m`);
320
+ console.log(` Internal: ${links.internal_link_count} External: ${links.external_link_count} Nofollow: ${links.nofollow_link_count} In content: ${links.links_in_content}`);
321
+ const aqColor = links.anchor_quality === "good" ? "\x1b[32m" : links.anchor_quality === "poor" ? "\x1b[31m" : "\x1b[33m";
322
+ console.log(` Anchor quality: ${aqColor}${links.anchor_quality}\x1b[0m Generic anchors: ${links.generic_anchor_count} External ratio: ${Math.round(links.external_link_ratio * 100)}%`);
323
+ if (links.broken_link_sample && links.broken_link_sample.length) {
324
+ console.log(` \x1b[31mBroken links:\x1b[0m`);
325
+ for (const bl of links.broken_link_sample) {
326
+ console.log(` \x1b[31m\u2717\x1b[0m ${bl}`);
327
+ }
328
+ }
329
+ }
330
+
331
+ // Blog Signals
332
+ const blog = data.blog_signals;
333
+ if (blog && blog.is_blog_post) {
334
+ console.log(`\n\x1b[1mBlog Signals\x1b[0m`);
335
+ const freshColor = blog.content_freshness === "fresh" ? "\x1b[32m" : blog.content_freshness === "stale" ? "\x1b[31m" : "\x1b[33m";
336
+ console.log(` Author: ${blog.has_author_name || "-"} Published: ${blog.has_publish_date || "-"} Freshness: ${freshColor}${blog.content_freshness}\x1b[0m Reading time: ${blog.estimated_reading_time} min`);
337
+ console.log(` ${check(blog.has_social_sharing)} Social sharing`);
338
+ }
339
+
212
340
  // AEO Signals
213
341
  const aeo = data.aeo_signals;
214
342
  if (aeo) {
215
- console.log("AEO Signals:");
216
- const check = (v) => (v ? "\x1b[32m\u2713\x1b[0m" : "\x1b[31m\u2717\x1b[0m");
343
+ console.log(`\n\x1b[1mAEO Signals\x1b[0m`);
217
344
  console.log(
218
345
  ` ${check(aeo.has_article_schema)} Article Schema ${check(aeo.has_faq_schema)} FAQPage Schema ${check(aeo.has_howto_schema)} HowTo Schema`
219
346
  );
@@ -221,15 +348,39 @@ export function registerCommands(program) {
221
348
  ` ${check(aeo.heading_hierarchy_valid)} Heading Hierarchy ${check(aeo.has_faq_content)} FAQ Content ${check(aeo.has_date_modified)} Date Modified`
222
349
  );
223
350
  console.log(
224
- ` ${check(aeo.has_speakable_schema)} Speakable Schema`
351
+ ` ${check(aeo.has_speakable_schema)} Speakable Schema ${check(aeo.has_breadcrumb_schema)} Breadcrumb Schema ${check(aeo.has_org_schema)} Org Schema`
352
+ );
353
+ console.log(
354
+ ` ${check(aeo.has_video_schema)} Video Schema`
225
355
  );
226
356
  console.log(
227
357
  ` Lists: ${aeo.list_count} Tables: ${aeo.table_count} Concise answers: ${aeo.concise_answer_count} Definitions: ${aeo.definition_count} Citations: ${aeo.citation_count}`
228
358
  );
359
+ console.log(
360
+ ` Questions: ${aeo.question_count} Direct answer ratio: ${(aeo.direct_answer_ratio * 100).toFixed(0)}%`
361
+ );
229
362
  if (aeo.avg_sentence_length > 0) {
230
363
  console.log(` Avg sentence length: ${aeo.avg_sentence_length} words`);
231
364
  }
232
365
  }
366
+
367
+ // GEO Signals
368
+ const geo = data.geo_signals;
369
+ if (geo) {
370
+ console.log(`\n\x1b[1mGEO Signals\x1b[0m`);
371
+ console.log(
372
+ ` ${check(geo.has_summary_section)} Summary Section ${check(geo.has_last_reviewed_date)} Reviewed Date ${check(geo.has_methodology_section)} Methodology`
373
+ );
374
+ console.log(
375
+ ` Quotable: ${geo.quotable_statement_count} Statistics: ${geo.statistic_count} Entities: ${geo.named_entity_count} Definitions: ${geo.definition_clarity_count}`
376
+ );
377
+ console.log(
378
+ ` Comparisons: ${geo.comparison_structure_count} Steps: ${geo.step_by_step_count} Sources: ${geo.source_attribution_count} Expertise: ${geo.author_expertise_signals}`
379
+ );
380
+ console.log(scoreBar("Topical Coverage", geo.topical_coverage_score));
381
+ console.log(scoreBar("Content Segmentation", geo.content_segmentation_score));
382
+ }
383
+
233
384
  const wc = data.word_count || 0;
234
385
  if (wc > 0 && wc < 50) {
235
386
  console.log(
@@ -260,6 +411,9 @@ export function registerCommands(program) {
260
411
  console.log(
261
412
  `\x1b[32mSynced ${data.synced_queries || 0} queries for ${data.site || opts.site}\x1b[0m`
262
413
  );
414
+ if (data.warning) {
415
+ console.log(`\x1b[33mWarning: ${data.warning}\x1b[0m`);
416
+ }
263
417
  }
264
418
  } catch (e) {
265
419
  handleError(e);
@@ -472,37 +626,463 @@ export function registerCommands(program) {
472
626
  }
473
627
  });
474
628
 
475
- // --- deploy ---
629
+ // --- history ---
476
630
  program
477
- .command("deploy")
478
- .description("Deploy Xyle services (API, frontend, trigger.dev)")
479
- .option("--api", "Deploy API to Cloud Run")
480
- .option("--frontend", "Deploy frontend to Vercel")
481
- .option("--trigger", "Deploy Trigger.dev tasks")
482
- .option("--dir <path>", "Project root directory", process.cwd())
631
+ .command("history")
632
+ .description("View snapshot history for a URL or site")
633
+ .option("--url <url>", "Page URL to view history for")
634
+ .option("--site <domain>", "Site domain to view history for")
635
+ .option("--limit <n>", "Max snapshots to return", "10")
636
+ .option("--json", "Output as JSON")
483
637
  .action(async (opts) => {
484
- const scriptPath = resolve(opts.dir, "scripts", "deploy.sh");
485
- if (!existsSync(scriptPath)) {
486
- process.stderr.write(
487
- `\x1b[31mDeploy script not found: ${scriptPath}\x1b[0m\n` +
488
- `\x1b[2mRun this command from the project root or use --dir <path>\x1b[0m\n`
489
- );
638
+ if (!opts.url && !opts.site) {
639
+ process.stderr.write("\x1b[31mProvide --url or --site\x1b[0m\n");
640
+ process.exit(1);
641
+ }
642
+ try {
643
+ const data = await listSnapshots(opts.url, opts.site, parseInt(opts.limit, 10));
644
+ if (opts.json) {
645
+ console.log(printJson(data));
646
+ } else if (!data || data.length === 0) {
647
+ console.log("\x1b[33mNo snapshots found.\x1b[0m");
648
+ } else {
649
+ const rows = data.map((s) => {
650
+ const seo = s.seo_score != null ? Math.round(s.seo_score * 100) : null;
651
+ const aeo = s.aeo_score != null ? Math.round(s.aeo_score * 100) : null;
652
+ const seoColor = seo != null ? (seo >= 70 ? "\x1b[32m" : seo >= 40 ? "\x1b[33m" : "\x1b[31m") : "\x1b[2m";
653
+ const aeoColor = aeo != null ? (aeo >= 70 ? "\x1b[32m" : aeo >= 40 ? "\x1b[33m" : "\x1b[31m") : "\x1b[2m";
654
+ return {
655
+ created_at: new Date(s.created_at).toLocaleString(),
656
+ trigger: s.trigger_source || "-",
657
+ seo_score: `${seoColor}${seo != null ? seo + "%" : "-"}\x1b[0m`,
658
+ aeo_score: `${aeoColor}${aeo != null ? aeo + "%" : "-"}\x1b[0m`,
659
+ signals: `${s.passing_signals}/${s.total_signals}`,
660
+ };
661
+ });
662
+ console.log(`\n\x1b[1mSnapshot History\x1b[0m (${data.length} snapshots)\n`);
663
+ console.log(printTable(rows, ["created_at", "trigger", "seo_score", "aeo_score", "signals"]));
664
+ }
665
+ } catch (e) {
666
+ handleError(e);
667
+ }
668
+ });
669
+
670
+ // --- trends ---
671
+ program
672
+ .command("trends")
673
+ .description("View score trends over time")
674
+ .option("--url <url>", "Page URL to view trends for")
675
+ .option("--site <domain>", "Site domain to view trends for")
676
+ .option("--days <n>", "Number of days to look back", "90")
677
+ .option("--json", "Output as JSON")
678
+ .action(async (opts) => {
679
+ if (!opts.url && !opts.site) {
680
+ process.stderr.write("\x1b[31mProvide --url or --site\x1b[0m\n");
490
681
  process.exit(1);
491
682
  }
683
+ try {
684
+ const data = await getSnapshotTrends(opts.url, opts.site, parseInt(opts.days, 10));
685
+ if (opts.json) {
686
+ console.log(printJson(data));
687
+ } else {
688
+ const points = data.points || [];
689
+ if (points.length === 0) {
690
+ console.log("\x1b[33mNo trend data found.\x1b[0m");
691
+ } else {
692
+ const target = data.url || data.site || opts.url || opts.site;
693
+ console.log(`\n\x1b[1mScore Trends\x1b[0m ${target} (${data.period_days} days)\n`);
694
+
695
+ // ASCII sparkline
696
+ const seoVals = points.map((p) => (p.seo_score != null ? Math.round(p.seo_score * 100) : null));
697
+ const aeoVals = points.map((p) => (p.aeo_score != null ? Math.round(p.aeo_score * 100) : null));
698
+ const geoVals = points.map((p) => (p.geo_score != null ? Math.round(p.geo_score * 100) : null));
699
+ const spark = (vals) => {
700
+ const ticks = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
701
+ const valid = vals.filter((v) => v != null);
702
+ if (valid.length === 0) return "-";
703
+ const min = Math.min(...valid);
704
+ const max = Math.max(...valid);
705
+ const range = max - min || 1;
706
+ return vals.map((v) => (v != null ? ticks[Math.min(7, Math.floor(((v - min) / range) * 7))] : " ")).join("");
707
+ };
708
+
709
+ console.log(` SEO ${spark(seoVals)} ${seoVals.filter((v) => v != null).slice(-1)[0] ?? "-"}%`);
710
+ console.log(` AEO ${spark(aeoVals)} ${aeoVals.filter((v) => v != null).slice(-1)[0] ?? "-"}%`);
711
+ console.log(` GEO ${spark(geoVals)} ${geoVals.filter((v) => v != null).slice(-1)[0] ?? "-"}%`);
712
+ console.log();
713
+ console.log(printTable(
714
+ points.map((p) => ({
715
+ date: p.date,
716
+ seo: p.seo_score != null ? Math.round(p.seo_score * 100) + "%" : "-",
717
+ aeo: p.aeo_score != null ? Math.round(p.aeo_score * 100) + "%" : "-",
718
+ geo: p.geo_score != null ? Math.round(p.geo_score * 100) + "%" : "-",
719
+ signals: p.passing_signals,
720
+ })),
721
+ ["date", "seo", "aeo", "geo", "signals"]
722
+ ));
723
+ }
724
+ }
725
+ } catch (e) {
726
+ handleError(e);
727
+ }
728
+ });
729
+
730
+ // --- diff ---
731
+ program
732
+ .command("diff")
733
+ .description("Compare two snapshots")
734
+ .requiredOption("--before <id>", "Before snapshot ID")
735
+ .requiredOption("--after <id>", "After snapshot ID")
736
+ .option("--json", "Output as JSON")
737
+ .action(async (opts) => {
738
+ try {
739
+ const data = await diffSnapshots(opts.before, opts.after);
740
+ if (opts.json) {
741
+ console.log(printJson(data));
742
+ } else {
743
+ console.log(`\n\x1b[1mSnapshot Diff\x1b[0m\n`);
744
+
745
+ const delta = (label, val) => {
746
+ if (val == null) return ` ${label.padEnd(20)} -`;
747
+ const pct = Math.round(val * 100);
748
+ const sign = pct > 0 ? "+" : "";
749
+ const color = pct > 0 ? "\x1b[32m" : pct < 0 ? "\x1b[31m" : "\x1b[2m";
750
+ return ` ${label.padEnd(20)} ${color}${sign}${pct}%\x1b[0m`;
751
+ };
752
+
753
+ console.log(delta("SEO Score", data.seo_delta));
754
+ console.log(delta("AEO Score", data.aeo_delta));
755
+ console.log(delta("GEO Score", data.geo_delta));
756
+
757
+ if (data.breakdown_delta) {
758
+ for (const [key, val] of Object.entries(data.breakdown_delta)) {
759
+ console.log(delta(` ${key}`, val));
760
+ }
761
+ }
762
+
763
+ const sigDelta = data.signals_delta || 0;
764
+ const sigColor = sigDelta > 0 ? "\x1b[32m" : sigDelta < 0 ? "\x1b[31m" : "\x1b[2m";
765
+ const sigSign = sigDelta > 0 ? "+" : "";
766
+ console.log(` ${"Signals".padEnd(20)} ${sigColor}${sigSign}${sigDelta}\x1b[0m`);
767
+
768
+ const resolved = data.resolved_issues || [];
769
+ if (resolved.length) {
770
+ console.log(`\n \x1b[32mResolved:\x1b[0m`);
771
+ for (const issue of resolved) {
772
+ console.log(` \x1b[32m\u2713\x1b[0m ${issue}`);
773
+ }
774
+ }
775
+
776
+ const newIssues = data.new_issues || [];
777
+ if (newIssues.length) {
778
+ console.log(`\n \x1b[31mNew Issues:\x1b[0m`);
779
+ for (const issue of newIssues) {
780
+ console.log(` \x1b[31m\u2717\x1b[0m ${issue}`);
781
+ }
782
+ }
783
+ }
784
+ } catch (e) {
785
+ handleError(e);
786
+ }
787
+ });
788
+
789
+ // --- ask ---
790
+ program
791
+ .command("ask")
792
+ .description("Query the knowledge base for expert SEO/AEO/GEO guidance")
793
+ .requiredOption("--question <text>", "Question to ask the knowledge base")
794
+ .option("--url <url>", "Context URL for the question")
795
+ .option("--topic <topic>", "Topic filter (e.g., schema, technical-seo)")
796
+ .option("--sources <n>", "Number of sources to retrieve", "5")
797
+ .option("--json", "Output as JSON")
798
+ .action(async (opts) => {
799
+ try {
800
+ const data = await askKnowledgeBase(
801
+ opts.question,
802
+ opts.url,
803
+ opts.topic,
804
+ parseInt(opts.sources, 10)
805
+ );
806
+ if (opts.json) {
807
+ console.log(printJson(data));
808
+ } else {
809
+ console.log(`\n\x1b[1mAnswer\x1b[0m${data.grounded ? " \x1b[32m(grounded)\x1b[0m" : ""}\n`);
810
+ console.log(data.answer);
811
+ console.log();
812
+ }
813
+ } catch (e) {
814
+ handleError(e);
815
+ }
816
+ });
817
+
818
+ // --- geo ---
819
+ program
820
+ .command("geo")
821
+ .description("Show GEO (Generative Engine Optimization) signals and score for a URL")
822
+ .requiredOption("--url <url>", "URL to analyze for GEO signals")
823
+ .option("--json", "Output as JSON")
824
+ .action(async (opts) => {
825
+ try {
826
+ const data = await crawlPage(opts.url);
827
+ if (opts.json) {
828
+ console.log(printJson({
829
+ url: data.url,
830
+ geo_signals: data.geo_signals,
831
+ }));
832
+ } else {
833
+ const geo = data.geo_signals;
834
+ if (!geo) {
835
+ console.log("\x1b[33mGEO signals not available for this page.\x1b[0m");
836
+ return;
837
+ }
838
+ const check = (v) => (v ? "\x1b[32m\u2713\x1b[0m" : "\x1b[31m\u2717\x1b[0m");
839
+ const scoreBar = (label, score) => {
840
+ const pct = Math.round((score || 0) * 100);
841
+ const filled = Math.round(pct / 5);
842
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
843
+ const color = pct >= 70 ? "\x1b[32m" : pct >= 40 ? "\x1b[33m" : "\x1b[31m";
844
+ return ` ${label.padEnd(28)} ${color}${bar} ${pct}%\x1b[0m`;
845
+ };
846
+
847
+ console.log(`\n\x1b[1mGEO Signals\x1b[0m ${data.url}\n`);
848
+
849
+ console.log(`\x1b[1mCitability (40%)\x1b[0m`);
850
+ console.log(` Quotable statements: ${geo.quotable_statement_count}`);
851
+ console.log(` Statistics: ${geo.statistic_count}`);
852
+ console.log(scoreBar("Unique insight density", geo.unique_insight_density));
853
+ console.log(` Source attributions: ${geo.source_attribution_count}`);
854
+
855
+ console.log(`\n\x1b[1mEntity/Topical (25%)\x1b[0m`);
856
+ console.log(` Named entities: ${geo.named_entity_count}`);
857
+ console.log(scoreBar("Topical coverage", geo.topical_coverage_score));
858
+ console.log(` Clear definitions: ${geo.definition_clarity_count}`);
859
+
860
+ console.log(`\n\x1b[1mStructural (25%)\x1b[0m`);
861
+ console.log(` ${check(geo.has_summary_section)} Summary/TL;DR section`);
862
+ console.log(` Comparison structures: ${geo.comparison_structure_count}`);
863
+ console.log(` Step-by-step content: ${geo.step_by_step_count}`);
864
+ console.log(scoreBar("Content segmentation", geo.content_segmentation_score));
865
+
866
+ console.log(`\n\x1b[1mAuthority (10%)\x1b[0m`);
867
+ console.log(` ${check(geo.has_last_reviewed_date)} Last reviewed date`);
868
+ console.log(` Expertise signals: ${geo.author_expertise_signals}`);
869
+ console.log(` ${check(geo.has_methodology_section)} Methodology section`);
870
+ console.log(scoreBar("Authority link ratio", geo.outbound_authority_ratio));
871
+ }
872
+ } catch (e) {
873
+ handleError(e);
874
+ }
875
+ });
876
+
877
+ // --- site-crawl ---
878
+ const siteCrawlCmd = program
879
+ .command("site-crawl")
880
+ .description("Full-site SEO crawler — Screaming Frog-style audit");
881
+
882
+ // site-crawl <url> — start a crawl and poll until complete
883
+ siteCrawlCmd
884
+ .command("start")
885
+ .description("Start a full-site crawl")
886
+ .argument("<url>", "Seed URL to crawl")
887
+ .option("--max-pages <n>", "Maximum pages to crawl", "500")
888
+ .option("--max-depth <n>", "Maximum crawl depth", "5")
889
+ .option("--no-robots", "Ignore robots.txt")
890
+ .option("--no-js", "Skip JavaScript rendering")
891
+ .option("--json", "Output as JSON (no live polling)")
892
+ .action(async (url, opts) => {
893
+ try {
894
+ const config = {
895
+ max_pages: parseInt(opts.maxPages, 10),
896
+ max_depth: parseInt(opts.maxDepth, 10),
897
+ respect_robots: opts.robots !== false,
898
+ render_js: opts.js !== false,
899
+ };
900
+ const data = await startSiteCrawl(url, config);
901
+ const jobId = data.job_id;
902
+
903
+ if (opts.json) {
904
+ console.log(printJson(data));
905
+ return;
906
+ }
907
+
908
+ console.log(`\x1b[36mCrawl started:\x1b[0m ${jobId}`);
909
+
910
+ // Poll every 2 seconds
911
+ const POLL_MS = 2000;
912
+ let prev = 0;
913
+ while (true) {
914
+ await new Promise((r) => setTimeout(r, POLL_MS));
915
+ const status = await getSiteCrawlStatus(jobId);
916
+ const crawled = status.pages_crawled || 0;
917
+ const discovered = status.pages_discovered || 0;
918
+ const errors = status.errors_count || 0;
919
+
920
+ if (crawled !== prev) {
921
+ const pct = discovered > 0 ? Math.round((crawled / discovered) * 100) : 0;
922
+ const bar = "\u2588".repeat(Math.round(pct / 5)) + "\u2591".repeat(20 - Math.round(pct / 5));
923
+ process.stdout.write(`\r ${bar} ${pct}% ${crawled}/${discovered} pages ${errors} errors`);
924
+ prev = crawled;
925
+ }
926
+
927
+ if (["completed", "failed", "cancelled"].includes(status.status)) {
928
+ process.stdout.write("\n");
929
+ if (status.status === "completed") {
930
+ const h = status.health_score != null ? Math.round(status.health_score * 100) : null;
931
+ const hColor = h != null ? (h >= 70 ? "\x1b[32m" : h >= 40 ? "\x1b[33m" : "\x1b[31m") : "\x1b[2m";
932
+ console.log(`\x1b[32mCompleted.\x1b[0m Pages: ${crawled} Errors: ${errors}`);
933
+ if (h != null) {
934
+ console.log(`Site Health: ${hColor}${h}%\x1b[0m`);
935
+ }
936
+ console.log(`\nView pages: xyle site-crawl pages ${jobId}`);
937
+ console.log(`View issues: xyle site-crawl issues ${jobId}`);
938
+ } else {
939
+ console.log(`\x1b[31mCrawl ${status.status}\x1b[0m${status.error_message ? ": " + status.error_message : ""}`);
940
+ }
941
+ break;
942
+ }
943
+ }
944
+ } catch (e) {
945
+ handleError(e);
946
+ }
947
+ });
948
+
949
+ // site-crawl status <job_id>
950
+ siteCrawlCmd
951
+ .command("status")
952
+ .description("Check crawl job status")
953
+ .argument("<jobId>", "Crawl job ID")
954
+ .option("--json", "Output as JSON")
955
+ .action(async (jobId, opts) => {
956
+ try {
957
+ const data = await getSiteCrawlStatus(jobId);
958
+ if (opts.json) {
959
+ console.log(printJson(data));
960
+ } else {
961
+ const color = data.status === "completed" ? "\x1b[32m" : data.status === "failed" ? "\x1b[31m" : "\x1b[33m";
962
+ console.log(`Status: ${color}${data.status}\x1b[0m`);
963
+ console.log(`Pages: ${data.pages_crawled}/${data.pages_discovered} Errors: ${data.errors_count}`);
964
+ if (data.health_score != null) {
965
+ console.log(`Health: ${Math.round(data.health_score * 100)}%`);
966
+ }
967
+ }
968
+ } catch (e) {
969
+ handleError(e);
970
+ }
971
+ });
492
972
 
493
- const flags = [];
494
- if (opts.api) flags.push("--api");
495
- if (opts.frontend) flags.push("--frontend");
496
- if (opts.trigger) flags.push("--trigger");
497
- // No flags = deploy all (script's default behavior)
973
+ // site-crawl pages <job_id>
974
+ siteCrawlCmd
975
+ .command("pages")
976
+ .description("List crawled pages")
977
+ .argument("<jobId>", "Crawl job ID")
978
+ .option("--limit <n>", "Results per page", "50")
979
+ .option("--filter <type>", "Filter: broken, redirect, thin")
980
+ .option("--json", "Output as JSON")
981
+ .action(async (jobId, opts) => {
982
+ try {
983
+ const data = await getSiteCrawlPages(jobId, {
984
+ limit: parseInt(opts.limit, 10),
985
+ filter: opts.filter,
986
+ });
987
+ if (opts.json) {
988
+ console.log(printJson(data));
989
+ } else {
990
+ console.log(`\n\x1b[1mCrawled Pages\x1b[0m (${data.total} total)\n`);
991
+ const rows = (data.pages || []).map((p) => ({
992
+ status: p.http_status || "-",
993
+ depth: p.depth,
994
+ seo: p.seo_score != null ? Math.round(p.seo_score * 100) + "%" : "-",
995
+ aeo: p.aeo_score != null ? Math.round(p.aeo_score * 100) + "%" : "-",
996
+ geo: p.geo_score != null ? Math.round(p.geo_score * 100) + "%" : "-",
997
+ words: p.word_count,
998
+ issues: p.issues_count,
999
+ url: p.url.length > 60 ? p.url.slice(0, 57) + "..." : p.url,
1000
+ }));
1001
+ console.log(printTable(rows, ["status", "depth", "seo", "aeo", "geo", "words", "issues", "url"]));
1002
+ }
1003
+ } catch (e) {
1004
+ handleError(e);
1005
+ }
1006
+ });
498
1007
 
499
- const cmd = `bash "${scriptPath}" ${flags.join(" ")}`;
500
- console.log(`\x1b[36mRunning:\x1b[0m ${cmd}\n`);
1008
+ // site-crawl issues <job_id>
1009
+ siteCrawlCmd
1010
+ .command("issues")
1011
+ .description("List crawl issues")
1012
+ .argument("<jobId>", "Crawl job ID")
1013
+ .option("--severity <level>", "Filter: critical, warning, info")
1014
+ .option("--category <cat>", "Filter by category")
1015
+ .option("--json", "Output as JSON")
1016
+ .action(async (jobId, opts) => {
501
1017
  try {
502
- execSync(cmd, { stdio: "inherit", cwd: opts.dir });
1018
+ const data = await getSiteCrawlIssues(jobId, {
1019
+ severity: opts.severity,
1020
+ category: opts.category,
1021
+ });
1022
+ if (opts.json) {
1023
+ console.log(printJson(data));
1024
+ } else {
1025
+ const counts = data.counts_by_severity || {};
1026
+ console.log(`\n\x1b[1mCrawl Issues\x1b[0m \x1b[31m${counts.critical || 0} critical\x1b[0m \x1b[33m${counts.warning || 0} warning\x1b[0m \x1b[2m${counts.info || 0} info\x1b[0m\n`);
1027
+ for (const issue of data.issues || []) {
1028
+ const sevColor = issue.severity === "critical" ? "\x1b[31m" : issue.severity === "warning" ? "\x1b[33m" : "\x1b[2m";
1029
+ console.log(` ${sevColor}[${issue.severity}]\x1b[0m \x1b[36m${issue.category}\x1b[0m ${issue.message}`);
1030
+ }
1031
+ }
503
1032
  } catch (e) {
504
- process.stderr.write(`\x1b[31mDeploy failed.\x1b[0m\n`);
505
- process.exit(e.status || 1);
1033
+ handleError(e);
506
1034
  }
507
1035
  });
1036
+
1037
+ // site-crawl list
1038
+ siteCrawlCmd
1039
+ .command("list")
1040
+ .description("List recent crawl jobs")
1041
+ .option("--json", "Output as JSON")
1042
+ .action(async (opts) => {
1043
+ try {
1044
+ const data = await listSiteCrawls();
1045
+ if (opts.json) {
1046
+ console.log(printJson(data));
1047
+ } else {
1048
+ const jobs = data.jobs || [];
1049
+ if (!jobs.length) {
1050
+ console.log("\x1b[33mNo crawl jobs found.\x1b[0m");
1051
+ return;
1052
+ }
1053
+ const rows = jobs.map((j) => ({
1054
+ id: j.job_id.slice(0, 8),
1055
+ status: j.status,
1056
+ pages: j.pages_crawled,
1057
+ errors: j.errors_count,
1058
+ health: j.health_score != null ? Math.round(j.health_score * 100) + "%" : "-",
1059
+ seed: j.seed_url.length > 40 ? j.seed_url.slice(0, 37) + "..." : j.seed_url,
1060
+ }));
1061
+ console.log(printTable(rows, ["id", "status", "pages", "errors", "health", "seed"]));
1062
+ }
1063
+ } catch (e) {
1064
+ handleError(e);
1065
+ }
1066
+ });
1067
+
1068
+ // --- kb-stats ---
1069
+ program
1070
+ .command("kb-stats")
1071
+ .description("Show knowledge base indexing statistics")
1072
+ .option("--json", "Output as JSON")
1073
+ .action(async (opts) => {
1074
+ try {
1075
+ const data = await getKbStats();
1076
+ if (opts.json) {
1077
+ console.log(printJson(data));
1078
+ } else {
1079
+ console.log(`\n\x1b[1mKnowledge Base\x1b[0m\n`);
1080
+ const ready = data.ready === true || data.status === "indexed";
1081
+ console.log(` Status: ${ready ? "\x1b[32mready\x1b[0m" : "\x1b[31mnot ready\x1b[0m"}`);
1082
+ }
1083
+ } catch (e) {
1084
+ handleError(e);
1085
+ }
1086
+ });
1087
+
508
1088
  }
package/src/seed.mjs CHANGED
@@ -89,6 +89,10 @@ npx @xyleapp/cli <command> [options]
89
89
  | \`xyle sync --site <url> [--json]\` | \`--site\` (required) | Syncs Search Console data; returns synced_queries count |
90
90
  | \`xyle queries --site <domain> [--limit N] [--json]\` | \`--site\` (required), \`--limit\` (default 20) | query, impressions, clicks, ctr, position |
91
91
  | \`xyle crawl --url <url> [--json]\` | \`--url\` (required) | title, meta_desc, word_count, headings |
92
+ | \`xyle site-crawl <url> [--max-pages N] [--max-depth N] [--no-robots] [--no-js] [--json]\` | \`url\` (required) | Full-site BFS crawl; returns job_id, polls to completion, prints site health score |
93
+ | \`xyle site-crawl status <job_id>\` | \`job_id\` | Crawl job status + progress |
94
+ | \`xyle site-crawl pages <job_id> [--limit N] [--filter broken\\|redirect\\|thin] [--json]\` | \`job_id\` | Per-page SEO/AEO/GEO scores |
95
+ | \`xyle site-crawl issues <job_id> [--severity critical\\|warning] [--json]\` | \`job_id\` | Site-wide issues (broken links, duplicates, orphans, thin content, canonicals) |
92
96
 
93
97
  ### Analysis
94
98
  | Command | Key Flags | Returns |
@@ -112,8 +116,19 @@ Always use \`--json\` when parsing output programmatically.
112
116
 
113
117
  ## Strategic Workflows
114
118
 
115
- ### 1. Full SEO Audit
116
- **When:** User wants a health check on their site's SEO performance.
119
+ ### 1. Full-Site Audit (Screaming Frog replacement)
120
+ **When:** User wants a whole-site health report, not a single-page audit.
121
+ **Goal:** BFS every internal page, detect site-wide issues, deliver a prioritized fix plan with a Site Health Score.
122
+
123
+ 1. \`xyle status --json\` — verify connectivity
124
+ 2. \`xyle site-crawl https://<domain> --max-pages 500 --json\` — run the crawl; the CLI polls until complete and prints a progress bar
125
+ 3. \`xyle site-crawl issues <job_id> --severity critical --json\` — triage broken links, redirect chains, canonical mismatches, duplicates, orphans, thin content
126
+ 4. \`xyle site-crawl pages <job_id> --filter thin --json\` — find pages that need content work
127
+ 5. \`xyle site-crawl pages <job_id> --limit 50 --json\` — sort by lowest SEO/AEO/GEO scores
128
+ 6. **Deliver a prioritized report**: Site Health Score, critical issue count, top 10 lowest-scoring pages, 30-day fix roadmap
129
+
130
+ ### 2. Single-Page / Query-Driven SEO Audit
131
+ **When:** User wants a deep dive on one page or a performance check driven by Search Console data.
117
132
  **Goal:** Categorize queries by intent, flag striking-distance opportunities, and deliver a prioritized action plan.
118
133
 
119
134
  1. \`xyle status --json\` — verify connectivity
@@ -174,7 +189,8 @@ When the user asks something SEO-related, route to the right workflow:
174
189
 
175
190
  | User Says | Workflow | Why |
176
191
  |-----------|----------|-----|
177
- | "How's my SEO?" / "Audit my site" | Full SEO Audit | Need holistic view before specific fixes |
192
+ | "Audit my whole site" / "Crawl my site" / "Screaming Frog" / "Site health" | Full-Site Audit | Need site-wide view: broken links, duplicates, orphans, thin content, link graph |
193
+ | "How's my SEO?" / "Audit my site" | Single-Page / Query-Driven SEO Audit | Need holistic view before specific fixes |
178
194
  | "Optimize this page" / "Improve rankings for X" | Page Optimization | Specific page needs score-based action |
179
195
  | "Who is my audience?" / "What should I write about?" | ICP Discovery | Need strategy before tactics |
180
196
  | "What content am I missing?" / "Find gaps" | Content Gap Sprint | Ready to create, need briefs |