@xyleapp/cli 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/README.md +66 -0
- package/bin/xyle.mjs +15 -0
- package/package.json +28 -0
- package/src/api.mjs +110 -0
- package/src/auth.mjs +164 -0
- package/src/commands.mjs +287 -0
- package/src/formatting.mjs +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# xyle
|
|
2
|
+
|
|
3
|
+
CLI for the [Xyle](https://xyle.app) SEO Intelligence Engine.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g xyle
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18+.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Check API connectivity
|
|
17
|
+
xyle status
|
|
18
|
+
|
|
19
|
+
# Authenticate with Google (opens browser)
|
|
20
|
+
xyle login
|
|
21
|
+
|
|
22
|
+
# List top queries for your site
|
|
23
|
+
xyle queries --site example.com
|
|
24
|
+
|
|
25
|
+
# Analyze competitor pages
|
|
26
|
+
xyle competitors --query "best seo tools"
|
|
27
|
+
|
|
28
|
+
# Find content gaps
|
|
29
|
+
xyle gaps --page https://example.com/blog/seo-guide
|
|
30
|
+
|
|
31
|
+
# Get AI rewrite suggestions
|
|
32
|
+
xyle rewrite --url https://example.com/blog/seo-guide --type title
|
|
33
|
+
|
|
34
|
+
# Crawl a page
|
|
35
|
+
xyle crawl --url https://example.com/blog/seo-guide
|
|
36
|
+
|
|
37
|
+
# Sync Search Console data
|
|
38
|
+
xyle sync --site https://example.com
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | Description |
|
|
44
|
+
| ------------- | ---------------------------------------- |
|
|
45
|
+
| `status` | Check if the SEO API is reachable |
|
|
46
|
+
| `queries` | List top search queries for a site |
|
|
47
|
+
| `competitors` | Show competitor pages for a query |
|
|
48
|
+
| `gaps` | Show content gaps for a page |
|
|
49
|
+
| `analyze` | Analyze page content against competitors |
|
|
50
|
+
| `rewrite` | Get AI rewrite suggestions |
|
|
51
|
+
| `crawl` | Crawl a URL and extract SEO metadata |
|
|
52
|
+
| `sync` | Sync Google Search Console data |
|
|
53
|
+
| `login` | Authenticate with Google OAuth |
|
|
54
|
+
| `logout` | Remove stored credentials |
|
|
55
|
+
| `whoami` | Show current authentication status |
|
|
56
|
+
|
|
57
|
+
All data commands accept `--json` for machine-readable output.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
| Environment Variable | Default | Description |
|
|
62
|
+
| -------------------- | ------------------------ | -------------------- |
|
|
63
|
+
| `SEO_BASE` | `http://localhost:8765` | API base URL |
|
|
64
|
+
| `AGENT_API_KEY` | `local-agent-secret-key` | Fallback API key |
|
|
65
|
+
|
|
66
|
+
Credentials are stored in `~/.config/xyle/credentials.json` (shared with the Python CLI).
|
package/bin/xyle.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { registerCommands } from "../src/commands.mjs";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("xyle")
|
|
10
|
+
.description("SEO Intelligence Engine CLI")
|
|
11
|
+
.version("0.1.0");
|
|
12
|
+
|
|
13
|
+
registerCommands(program);
|
|
14
|
+
|
|
15
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xyleapp/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for the Xyle SEO Intelligence Engine",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"xyle": "bin/xyle.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"seo",
|
|
19
|
+
"cli",
|
|
20
|
+
"search-console",
|
|
21
|
+
"xyle"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^13.0.0",
|
|
26
|
+
"open": "^10.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Xyle SEO API.
|
|
3
|
+
* Uses native fetch (Node 18+). Zero external dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getApiKey } from "./auth.mjs";
|
|
7
|
+
|
|
8
|
+
const SEO_BASE = process.env.SEO_BASE || "http://localhost:8765";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build auth headers, preferring personal API key over shared env key.
|
|
12
|
+
*/
|
|
13
|
+
function getHeaders() {
|
|
14
|
+
let key;
|
|
15
|
+
try {
|
|
16
|
+
key = getApiKey();
|
|
17
|
+
} catch {
|
|
18
|
+
key = null;
|
|
19
|
+
}
|
|
20
|
+
if (!key) {
|
|
21
|
+
key = process.env.AGENT_API_KEY || "local-agent-secret-key";
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
"X-Agent-Key": key,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Make an API request. Throws on non-OK responses with a structured error.
|
|
31
|
+
*/
|
|
32
|
+
async function request(method, path, { params, body, timeout = 30000, auth = true } = {}) {
|
|
33
|
+
let url = `${SEO_BASE}${path}`;
|
|
34
|
+
if (params) {
|
|
35
|
+
const qs = new URLSearchParams();
|
|
36
|
+
for (const [k, v] of Object.entries(params)) {
|
|
37
|
+
if (v != null) qs.set(k, String(v));
|
|
38
|
+
}
|
|
39
|
+
const qsStr = qs.toString();
|
|
40
|
+
if (qsStr) url += `?${qsStr}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const headers = auth ? getHeaders() : { "Content-Type": "application/json" };
|
|
44
|
+
const opts = { method, headers, signal: AbortSignal.timeout(timeout) };
|
|
45
|
+
if (body) opts.body = JSON.stringify(body);
|
|
46
|
+
|
|
47
|
+
const resp = await fetch(url, opts);
|
|
48
|
+
if (!resp.ok) {
|
|
49
|
+
let detail;
|
|
50
|
+
try {
|
|
51
|
+
const json = await resp.json();
|
|
52
|
+
detail = json.detail || resp.statusText;
|
|
53
|
+
} catch {
|
|
54
|
+
detail = resp.statusText;
|
|
55
|
+
}
|
|
56
|
+
const err = new Error(`Error ${resp.status}: ${detail}`);
|
|
57
|
+
err.status = resp.status;
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
return resp.json();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checkHealth() {
|
|
64
|
+
return request("GET", "/health", { auth: false, timeout: 10000 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getTopQueries(site, limit = 20) {
|
|
68
|
+
return request("GET", "/queries", { params: { site, limit } });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getCompetitors(query) {
|
|
72
|
+
return request("GET", "/competitors", { params: { query } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getPageGaps(page, query) {
|
|
76
|
+
return request("GET", "/gaps", { params: { page, query } });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function analyzePage(url, content, query) {
|
|
80
|
+
const body = { url, content };
|
|
81
|
+
if (query) body.query = query;
|
|
82
|
+
return request("POST", "/analyze", { body, timeout: 60000 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getRewriteSuggestions(url, type = "title", query) {
|
|
86
|
+
const body = { url, type, context: {} };
|
|
87
|
+
if (query) body.query = query;
|
|
88
|
+
return request("POST", "/rewrite", { body, timeout: 60000 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function crawlPage(url) {
|
|
92
|
+
return request("POST", "/crawl", { body: { url } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function syncGsc(site) {
|
|
96
|
+
return request("POST", "/admin/sync", { params: { site } });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetch the OAuth auth URL from the API.
|
|
101
|
+
*/
|
|
102
|
+
export function getAuthUrl(cliPort) {
|
|
103
|
+
return request("GET", "/admin/gsc/auth-url", {
|
|
104
|
+
params: { cli_port: cliPort },
|
|
105
|
+
auth: false,
|
|
106
|
+
timeout: 10000,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { SEO_BASE };
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth flow + credential storage for the xyle CLI.
|
|
3
|
+
* Credentials file (~/.config/xyle/credentials.json) is shared with the Python CLI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
const CREDENTIALS_DIR = join(homedir(), ".config", "xyle");
|
|
12
|
+
const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
|
|
13
|
+
const CALLBACK_PORT = 9876;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load stored credentials from disk.
|
|
17
|
+
* @returns {object|null}
|
|
18
|
+
*/
|
|
19
|
+
export function getCredentials() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the stored personal API key, or null.
|
|
30
|
+
* @returns {string|null}
|
|
31
|
+
*/
|
|
32
|
+
export function getApiKey() {
|
|
33
|
+
const creds = getCredentials();
|
|
34
|
+
if (creds && creds.authenticated) return creds.api_key || null;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Persist credentials to ~/.config/xyle/credentials.json (mode 600).
|
|
40
|
+
* @param {object} creds
|
|
41
|
+
*/
|
|
42
|
+
export function saveCredentials(creds) {
|
|
43
|
+
mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
44
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), "utf-8");
|
|
45
|
+
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remove stored credentials.
|
|
50
|
+
*/
|
|
51
|
+
export function clearCredentials() {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
54
|
+
} catch {
|
|
55
|
+
// already gone
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Browser-based OAuth login flow.
|
|
61
|
+
*
|
|
62
|
+
* 1. CLI starts local HTTP server on CALLBACK_PORT
|
|
63
|
+
* 2. CLI asks API for the Google OAuth URL (with cli_port in state)
|
|
64
|
+
* 3. Browser -> Google consent -> API callback -> redirects to CLI server
|
|
65
|
+
* 4. CLI server receives status, stores credentials
|
|
66
|
+
*
|
|
67
|
+
* @param {string} seoBase - API base URL
|
|
68
|
+
* @returns {Promise<object|null>}
|
|
69
|
+
*/
|
|
70
|
+
export async function runLoginFlow(seoBase) {
|
|
71
|
+
// Dynamic import so the rest of the module works without `open` at load time
|
|
72
|
+
const { default: openBrowser } = await import("open");
|
|
73
|
+
const { getAuthUrl } = await import("./api.mjs");
|
|
74
|
+
|
|
75
|
+
// Get auth URL from API
|
|
76
|
+
let authUrl;
|
|
77
|
+
try {
|
|
78
|
+
const data = await getAuthUrl(CALLBACK_PORT);
|
|
79
|
+
authUrl = data.auth_url;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e.cause && e.cause.code === "ECONNREFUSED") {
|
|
82
|
+
console.error("\x1b[31mAPI is not running. Start it first.\x1b[0m");
|
|
83
|
+
} else {
|
|
84
|
+
console.error(`\x1b[31mFailed to get auth URL from API: ${e.message}\x1b[0m`);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
|
|
92
|
+
const server = createServer((req, res) => {
|
|
93
|
+
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
94
|
+
if (url.pathname !== "/callback") {
|
|
95
|
+
res.writeHead(404);
|
|
96
|
+
res.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const status = url.searchParams.get("status");
|
|
101
|
+
const message = url.searchParams.get("message") || "";
|
|
102
|
+
|
|
103
|
+
if (status === "authenticated") {
|
|
104
|
+
const creds = {
|
|
105
|
+
authenticated: true,
|
|
106
|
+
seo_base: seoBase,
|
|
107
|
+
message,
|
|
108
|
+
};
|
|
109
|
+
const apiKey = url.searchParams.get("api_key");
|
|
110
|
+
const email = url.searchParams.get("email");
|
|
111
|
+
const name = url.searchParams.get("name");
|
|
112
|
+
if (apiKey) creds.api_key = apiKey;
|
|
113
|
+
if (email) creds.email = email;
|
|
114
|
+
if (name) creds.name = name;
|
|
115
|
+
|
|
116
|
+
saveCredentials(creds);
|
|
117
|
+
|
|
118
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
119
|
+
res.end(
|
|
120
|
+
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>" +
|
|
121
|
+
"<h2 style='color:#22c55e'>Login successful!</h2>" +
|
|
122
|
+
"<p>You can close this tab and return to the terminal.</p>" +
|
|
123
|
+
"</body></html>"
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
settled = true;
|
|
127
|
+
server.close();
|
|
128
|
+
resolve(creds);
|
|
129
|
+
} else if (status === "error") {
|
|
130
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
131
|
+
res.end(
|
|
132
|
+
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>" +
|
|
133
|
+
"<h2 style='color:#ef4444'>Login failed</h2>" +
|
|
134
|
+
`<p>${message}</p>` +
|
|
135
|
+
"</body></html>"
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
settled = true;
|
|
139
|
+
server.close();
|
|
140
|
+
console.error(`\x1b[31mLogin failed: ${message}\x1b[0m`);
|
|
141
|
+
resolve(null);
|
|
142
|
+
} else {
|
|
143
|
+
res.writeHead(404);
|
|
144
|
+
res.end();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
server.listen(CALLBACK_PORT, "localhost", () => {
|
|
149
|
+
console.log("Opening browser for Google login...");
|
|
150
|
+
console.log(`If the browser doesn't open, visit:\n${authUrl}\n`);
|
|
151
|
+
openBrowser(authUrl).catch(() => {});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// 120-second timeout
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
if (!settled) {
|
|
157
|
+
settled = true;
|
|
158
|
+
server.close();
|
|
159
|
+
console.error("\x1b[31mLogin timed out (2 minutes). Try again.\x1b[0m");
|
|
160
|
+
resolve(null);
|
|
161
|
+
}
|
|
162
|
+
}, 120_000);
|
|
163
|
+
});
|
|
164
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All 11 CLI commands for the xyle CLI.
|
|
3
|
+
* Mirrors the Python CLI 1:1.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { printJson, printTable } from "./formatting.mjs";
|
|
8
|
+
import {
|
|
9
|
+
checkHealth,
|
|
10
|
+
getTopQueries,
|
|
11
|
+
getCompetitors,
|
|
12
|
+
getPageGaps,
|
|
13
|
+
analyzePage,
|
|
14
|
+
getRewriteSuggestions,
|
|
15
|
+
crawlPage,
|
|
16
|
+
syncGsc,
|
|
17
|
+
SEO_BASE,
|
|
18
|
+
} from "./api.mjs";
|
|
19
|
+
import { getCredentials, clearCredentials, runLoginFlow } from "./auth.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Print error message in red to stderr and exit.
|
|
23
|
+
*/
|
|
24
|
+
function handleError(e) {
|
|
25
|
+
const msg = e.message || String(e);
|
|
26
|
+
process.stderr.write(`\x1b[31m${msg}\x1b[0m\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register all commands on a Commander program instance.
|
|
32
|
+
* @param {import('commander').Command} program
|
|
33
|
+
*/
|
|
34
|
+
export function registerCommands(program) {
|
|
35
|
+
// --- status ---
|
|
36
|
+
program
|
|
37
|
+
.command("status")
|
|
38
|
+
.description("Check if the SEO API is reachable")
|
|
39
|
+
.option("--json", "Output as JSON")
|
|
40
|
+
.action(async (opts) => {
|
|
41
|
+
try {
|
|
42
|
+
const data = await checkHealth();
|
|
43
|
+
if (opts.json) {
|
|
44
|
+
console.log(printJson(data));
|
|
45
|
+
} else {
|
|
46
|
+
const s = data.status || "unknown";
|
|
47
|
+
const color = s === "ok" ? "\x1b[32m" : "\x1b[31m";
|
|
48
|
+
console.log(`${color}API status: ${s}\x1b[0m`);
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.cause && e.cause.code === "ECONNREFUSED") {
|
|
52
|
+
process.stderr.write("\x1b[31mAPI unreachable\x1b[0m\n");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
handleError(e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- queries ---
|
|
60
|
+
program
|
|
61
|
+
.command("queries")
|
|
62
|
+
.description("List top search queries for a site")
|
|
63
|
+
.requiredOption("--site <domain>", "Site domain or URL")
|
|
64
|
+
.option("--limit <n>", "Max queries to return", "20")
|
|
65
|
+
.option("--json", "Output as JSON")
|
|
66
|
+
.action(async (opts) => {
|
|
67
|
+
try {
|
|
68
|
+
const data = await getTopQueries(opts.site, parseInt(opts.limit, 10));
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
console.log(printJson(data));
|
|
71
|
+
} else {
|
|
72
|
+
console.log(printTable(data, ["query", "impressions", "clicks", "ctr", "position"]));
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
handleError(e);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// --- competitors ---
|
|
80
|
+
program
|
|
81
|
+
.command("competitors")
|
|
82
|
+
.description("Show competitor pages for a query")
|
|
83
|
+
.requiredOption("--query <query>", "Search query to analyze")
|
|
84
|
+
.option("--json", "Output as JSON")
|
|
85
|
+
.action(async (opts) => {
|
|
86
|
+
try {
|
|
87
|
+
const data = await getCompetitors(opts.query);
|
|
88
|
+
if (opts.json) {
|
|
89
|
+
console.log(printJson(data));
|
|
90
|
+
} else {
|
|
91
|
+
console.log(printTable(data, ["position", "url", "title", "word_count"]));
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
handleError(e);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// --- gaps ---
|
|
99
|
+
program
|
|
100
|
+
.command("gaps")
|
|
101
|
+
.description("Show content gaps for a page")
|
|
102
|
+
.requiredOption("--page <url>", "Page URL to check gaps for")
|
|
103
|
+
.option("--query <query>", "Optional query to filter")
|
|
104
|
+
.option("--json", "Output as JSON")
|
|
105
|
+
.action(async (opts) => {
|
|
106
|
+
try {
|
|
107
|
+
const data = await getPageGaps(opts.page, opts.query);
|
|
108
|
+
if (opts.json) {
|
|
109
|
+
console.log(printJson(data));
|
|
110
|
+
} else {
|
|
111
|
+
console.log(
|
|
112
|
+
printTable(data, ["query", "missing_topics", "ctr_issue", "position_bucket"])
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
handleError(e);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- analyze ---
|
|
121
|
+
program
|
|
122
|
+
.command("analyze")
|
|
123
|
+
.description("Analyze page content against competitors")
|
|
124
|
+
.requiredOption("--url <url>", "Page URL to analyze")
|
|
125
|
+
.requiredOption("--content <text>", "Page content text")
|
|
126
|
+
.option("--query <query>", "Target query for analysis")
|
|
127
|
+
.option("--json", "Output as JSON")
|
|
128
|
+
.action(async (opts) => {
|
|
129
|
+
try {
|
|
130
|
+
const data = await analyzePage(opts.url, opts.content, opts.query);
|
|
131
|
+
if (opts.json) {
|
|
132
|
+
console.log(printJson(data));
|
|
133
|
+
} else {
|
|
134
|
+
const score = data.score || 0;
|
|
135
|
+
const color = score >= 0.7 ? "\x1b[32m" : "\x1b[33m";
|
|
136
|
+
console.log(`${color}Score: ${Math.round(score * 100)}%\x1b[0m`);
|
|
137
|
+
console.log(`Summary: ${data.summary || ""}`);
|
|
138
|
+
const missing = data.missing_topics || [];
|
|
139
|
+
if (missing.length) {
|
|
140
|
+
console.log(`Missing topics: ${missing.join(", ")}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
handleError(e);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- rewrite ---
|
|
149
|
+
program
|
|
150
|
+
.command("rewrite")
|
|
151
|
+
.description("Get AI rewrite suggestions for a page element")
|
|
152
|
+
.requiredOption("--url <url>", "Page URL to rewrite")
|
|
153
|
+
.option("--type <element>", "Element to rewrite: title, meta, heading, section", "title")
|
|
154
|
+
.option("--query <query>", "Target query for rewrite")
|
|
155
|
+
.option("--json", "Output as JSON")
|
|
156
|
+
.action(async (opts) => {
|
|
157
|
+
try {
|
|
158
|
+
const data = await getRewriteSuggestions(opts.url, opts.type, opts.query);
|
|
159
|
+
if (opts.json) {
|
|
160
|
+
console.log(printJson(data));
|
|
161
|
+
} else {
|
|
162
|
+
if (data.original) {
|
|
163
|
+
console.log(`\x1b[31mOriginal: ${data.original}\x1b[0m`);
|
|
164
|
+
}
|
|
165
|
+
console.log(`\x1b[32mSuggested: ${data.suggested}\x1b[0m`);
|
|
166
|
+
console.log(`Reasoning: ${data.reasoning}`);
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
handleError(e);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- crawl ---
|
|
174
|
+
program
|
|
175
|
+
.command("crawl")
|
|
176
|
+
.description("Crawl a URL and extract SEO metadata")
|
|
177
|
+
.requiredOption("--url <url>", "URL to crawl")
|
|
178
|
+
.option("--json", "Output as JSON")
|
|
179
|
+
.action(async (opts) => {
|
|
180
|
+
try {
|
|
181
|
+
const data = await crawlPage(opts.url);
|
|
182
|
+
if (opts.json) {
|
|
183
|
+
console.log(printJson(data));
|
|
184
|
+
} else {
|
|
185
|
+
console.log(`Title: ${data.title || "-"}`);
|
|
186
|
+
console.log(`Meta desc: ${data.meta_desc || "-"}`);
|
|
187
|
+
console.log(`Word count: ${data.word_count || 0}`);
|
|
188
|
+
const headings = data.headings || [];
|
|
189
|
+
if (headings.length) {
|
|
190
|
+
console.log("Headings:");
|
|
191
|
+
for (const h of headings) {
|
|
192
|
+
console.log(` ${h}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
handleError(e);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// --- sync ---
|
|
202
|
+
program
|
|
203
|
+
.command("sync")
|
|
204
|
+
.description("Sync Google Search Console data")
|
|
205
|
+
.requiredOption("--site <url>", "Site URL to sync")
|
|
206
|
+
.option("--json", "Output as JSON")
|
|
207
|
+
.action(async (opts) => {
|
|
208
|
+
try {
|
|
209
|
+
const data = await syncGsc(opts.site);
|
|
210
|
+
if (opts.json) {
|
|
211
|
+
console.log(printJson(data));
|
|
212
|
+
} else {
|
|
213
|
+
console.log(
|
|
214
|
+
`\x1b[32mSynced ${data.synced_queries || 0} queries for ${data.site || opts.site}\x1b[0m`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
handleError(e);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// --- login ---
|
|
223
|
+
program
|
|
224
|
+
.command("login")
|
|
225
|
+
.description("Authenticate with Google for Search Console access")
|
|
226
|
+
.action(async () => {
|
|
227
|
+
const seoBase = process.env.SEO_BASE || "http://localhost:8765";
|
|
228
|
+
|
|
229
|
+
const existing = getCredentials();
|
|
230
|
+
if (existing && existing.authenticated) {
|
|
231
|
+
const readline = await import("node:readline");
|
|
232
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
233
|
+
const answer = await new Promise((resolve) => {
|
|
234
|
+
rl.question("Already logged in. Re-authenticate? [y/N] ", resolve);
|
|
235
|
+
});
|
|
236
|
+
rl.close();
|
|
237
|
+
if (answer.toLowerCase() !== "y") return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const result = await runLoginFlow(seoBase);
|
|
241
|
+
if (result) {
|
|
242
|
+
const email = result.email || "";
|
|
243
|
+
if (email) {
|
|
244
|
+
console.log(`\x1b[32mLogged in as ${email}\x1b[0m`);
|
|
245
|
+
} else {
|
|
246
|
+
console.log("\x1b[32mLogin successful! Credentials saved.\x1b[0m");
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
process.stderr.write("\x1b[31mLogin failed.\x1b[0m\n");
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// --- logout ---
|
|
255
|
+
program
|
|
256
|
+
.command("logout")
|
|
257
|
+
.description("Remove stored credentials")
|
|
258
|
+
.action(() => {
|
|
259
|
+
clearCredentials();
|
|
260
|
+
console.log("\x1b[32mLogged out. Credentials removed.\x1b[0m");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// --- whoami ---
|
|
264
|
+
program
|
|
265
|
+
.command("whoami")
|
|
266
|
+
.description("Show current authentication status")
|
|
267
|
+
.action(() => {
|
|
268
|
+
const creds = getCredentials();
|
|
269
|
+
if (creds && creds.authenticated) {
|
|
270
|
+
const email = creds.email;
|
|
271
|
+
if (email) {
|
|
272
|
+
console.log(`\x1b[32mLogged in as ${email}\x1b[0m`);
|
|
273
|
+
} else {
|
|
274
|
+
console.log("\x1b[32mAuthenticated\x1b[0m");
|
|
275
|
+
}
|
|
276
|
+
const apiKey = creds.api_key || "";
|
|
277
|
+
if (apiKey) {
|
|
278
|
+
const prefix = apiKey.slice(0, 12);
|
|
279
|
+
const suffix = apiKey.length > 16 ? apiKey.slice(-4) : "";
|
|
280
|
+
console.log(`API key: ${prefix}...${suffix}`);
|
|
281
|
+
}
|
|
282
|
+
console.log(`API: ${creds.seo_base || "unknown"}`);
|
|
283
|
+
} else {
|
|
284
|
+
console.log("\x1b[33mNot authenticated. Run: xyle login\x1b[0m");
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting helpers for the xyle CLI.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pretty-print data as JSON.
|
|
7
|
+
* @param {any} data
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function printJson(data) {
|
|
11
|
+
return JSON.stringify(data, null, 2);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render rows as an ASCII table matching the Python CLI output.
|
|
16
|
+
* @param {Array<Object>} rows
|
|
17
|
+
* @param {string[]} columns
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function printTable(rows, columns) {
|
|
21
|
+
if (!rows || rows.length === 0) return "(no data)";
|
|
22
|
+
|
|
23
|
+
const widths = {};
|
|
24
|
+
for (const col of columns) {
|
|
25
|
+
widths[col] = col.length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const strRows = [];
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
const strRow = {};
|
|
31
|
+
for (const col of columns) {
|
|
32
|
+
const val = row[col];
|
|
33
|
+
let cell;
|
|
34
|
+
if (typeof val === "number" && !Number.isInteger(val)) {
|
|
35
|
+
cell = val < 1 ? val.toFixed(3) : val.toFixed(1);
|
|
36
|
+
} else if (Array.isArray(val)) {
|
|
37
|
+
cell = val.join(", ");
|
|
38
|
+
} else if (val == null) {
|
|
39
|
+
cell = "-";
|
|
40
|
+
} else {
|
|
41
|
+
cell = String(val);
|
|
42
|
+
}
|
|
43
|
+
strRow[col] = cell;
|
|
44
|
+
widths[col] = Math.max(widths[col], cell.length);
|
|
45
|
+
}
|
|
46
|
+
strRows.push(strRow);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const header = columns.map((c) => c.toUpperCase().padEnd(widths[c])).join(" ");
|
|
50
|
+
const separator = columns.map((c) => "-".repeat(widths[c])).join(" ");
|
|
51
|
+
const lines = [header, separator];
|
|
52
|
+
for (const strRow of strRows) {
|
|
53
|
+
lines.push(columns.map((c) => strRow[c].padEnd(widths[c])).join(" "));
|
|
54
|
+
}
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|