rankcontrol 0.1.0 → 0.6.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 +57 -4
- package/package.json +1 -1
- package/src/cli.mjs +504 -2
- package/src/client.mjs +152 -2
- package/src/login.mjs +111 -0
- package/src/mcp.mjs +545 -2
package/src/client.mjs
CHANGED
|
@@ -1,15 +1,63 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
1
5
|
const DEFAULT_BASE = "https://api.rctrl.com";
|
|
6
|
+
const DEFAULT_APP = "https://rctrl.com";
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = join(homedir(), ".rankcontrol");
|
|
9
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
10
|
+
|
|
11
|
+
export function readStoredConfig() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeStoredConfig(config) {
|
|
20
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
|
|
22
|
+
mode: 0o600,
|
|
23
|
+
});
|
|
24
|
+
return CONFIG_FILE;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deleteStoredConfig() {
|
|
28
|
+
if (!existsSync(CONFIG_FILE)) return false;
|
|
29
|
+
rmSync(CONFIG_FILE);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
2
32
|
|
|
3
33
|
export function getConfig() {
|
|
4
|
-
const
|
|
34
|
+
const stored = readStoredConfig();
|
|
35
|
+
const apiKey = process.env.RANKCONTROL_API_KEY || stored?.apiKey;
|
|
5
36
|
if (!apiKey) {
|
|
6
37
|
throw new Error(
|
|
7
|
-
"
|
|
38
|
+
"Not authenticated. Run `rankcontrol login`, or create a key in RankControl → Settings → API and export RANKCONTROL_API_KEY=rctrl_pk_..."
|
|
8
39
|
);
|
|
9
40
|
}
|
|
10
41
|
return {
|
|
11
42
|
apiKey,
|
|
43
|
+
baseUrl: (
|
|
44
|
+
process.env.RANKCONTROL_API_URL ||
|
|
45
|
+
stored?.baseUrl ||
|
|
46
|
+
DEFAULT_BASE
|
|
47
|
+
).replace(/\/$/, ""),
|
|
48
|
+
appUrl: (
|
|
49
|
+
process.env.RANKCONTROL_APP_URL ||
|
|
50
|
+
stored?.appUrl ||
|
|
51
|
+
DEFAULT_APP
|
|
52
|
+
).replace(/\/$/, ""),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Unauthenticated variant for the login flow itself
|
|
57
|
+
export function getBaseUrls() {
|
|
58
|
+
return {
|
|
12
59
|
baseUrl: (process.env.RANKCONTROL_API_URL || DEFAULT_BASE).replace(/\/$/, ""),
|
|
60
|
+
appUrl: (process.env.RANKCONTROL_APP_URL || DEFAULT_APP).replace(/\/$/, ""),
|
|
13
61
|
};
|
|
14
62
|
}
|
|
15
63
|
|
|
@@ -39,6 +87,14 @@ export const api = {
|
|
|
39
87
|
overviewFunnel: () => request("GET", "/api/v1/overview/funnel"),
|
|
40
88
|
visibilityTrend: (days = 30) =>
|
|
41
89
|
request("GET", `/api/v1/visibility/trend?days=${days}`),
|
|
90
|
+
shareOfVoice: (days = 30) =>
|
|
91
|
+
request("GET", `/api/v1/visibility/share-of-voice?days=${days}`),
|
|
92
|
+
citationSources: (days = 30) =>
|
|
93
|
+
request("GET", `/api/v1/visibility/sources?days=${days}`),
|
|
94
|
+
citationSentiment: (days = 30) =>
|
|
95
|
+
request("GET", `/api/v1/visibility/sentiment?days=${days}`),
|
|
96
|
+
optimizer: (limit = 25) =>
|
|
97
|
+
request("GET", `/api/v1/content/optimizer?limit=${limit}`),
|
|
42
98
|
citations: (params = {}) => {
|
|
43
99
|
const q = new URLSearchParams();
|
|
44
100
|
if (params.model) q.set("model", params.model);
|
|
@@ -55,4 +111,98 @@ export const api = {
|
|
|
55
111
|
request("POST", "/api/v1/content/plan/commit", opts),
|
|
56
112
|
publishContent: (contentId, confirm = false) =>
|
|
57
113
|
request("POST", "/api/v1/content/publish", { contentId, confirm }),
|
|
114
|
+
generateContent: (contentId, confirm = false) =>
|
|
115
|
+
request("POST", "/api/v1/content/generate", { contentId, confirm }),
|
|
116
|
+
internalLinks: (contentId) =>
|
|
117
|
+
request(
|
|
118
|
+
"GET",
|
|
119
|
+
`/api/v1/content/internal-links?contentId=${encodeURIComponent(contentId)}`
|
|
120
|
+
),
|
|
121
|
+
articleSettings: () => request("GET", "/api/v1/settings/articles"),
|
|
122
|
+
updateArticleSettings: (opts) =>
|
|
123
|
+
request("POST", "/api/v1/settings/articles", opts),
|
|
124
|
+
reschedule: (contentId, targetDayStartMs) =>
|
|
125
|
+
request("POST", "/api/v1/content/reschedule", { contentId, targetDayStartMs }),
|
|
126
|
+
sitePages: () => request("GET", "/api/v1/links/pages"),
|
|
127
|
+
detectSiteLinks: (source, url) =>
|
|
128
|
+
request("POST", "/api/v1/links/detect", { source, url }),
|
|
129
|
+
addSitePages: (urls) => request("POST", "/api/v1/links/pages", { urls }),
|
|
130
|
+
leads: (limit = 100) => request("GET", `/api/v1/leads?limit=${limit}`),
|
|
131
|
+
repurposeQueue: () => request("GET", "/api/v1/repurpose"),
|
|
132
|
+
repurposeDrafts: (contentId) =>
|
|
133
|
+
request(
|
|
134
|
+
"GET",
|
|
135
|
+
`/api/v1/repurpose?contentId=${encodeURIComponent(contentId)}`
|
|
136
|
+
),
|
|
137
|
+
repurposeChannels: () => request("GET", "/api/v1/repurpose/channels"),
|
|
138
|
+
repurposeGenerate: (opts) =>
|
|
139
|
+
request("POST", "/api/v1/repurpose/generate", opts),
|
|
140
|
+
repurposeEditDraft: (opts) => request("POST", "/api/v1/repurpose/draft", opts),
|
|
141
|
+
repurposeMarkPosted: (draftId) =>
|
|
142
|
+
request("POST", "/api/v1/repurpose/mark-posted", { draftId }),
|
|
143
|
+
repurposePush: (opts) => request("POST", "/api/v1/repurpose/push", opts),
|
|
144
|
+
trackedQueries: () => request("GET", "/api/v1/queries"),
|
|
145
|
+
addQuery: (queryText) => request("POST", "/api/v1/queries", { queryText }),
|
|
146
|
+
crawlerAccess: () => request("GET", "/api/v1/crawler-access"),
|
|
147
|
+
trafficOverview: (days = 30) =>
|
|
148
|
+
request("GET", `/api/v1/analytics/traffic?days=${days}`),
|
|
149
|
+
team: () => request("GET", "/api/v1/team"),
|
|
150
|
+
teamInvite: (opts) => request("POST", "/api/v1/team/invite", opts),
|
|
151
|
+
teamRevoke: (invitationId) =>
|
|
152
|
+
request("POST", "/api/v1/team/revoke", { invitationId }),
|
|
153
|
+
teamRemove: (opts) => request("POST", "/api/v1/team/remove", opts),
|
|
154
|
+
outreachProspects: () => request("GET", "/api/v1/outreach/prospects"),
|
|
155
|
+
outreachFindContact: (backlinkId) =>
|
|
156
|
+
request("POST", "/api/v1/outreach/find-contact", { backlinkId }),
|
|
157
|
+
outreachQueue: (opts) => request("POST", "/api/v1/outreach/queue", opts),
|
|
158
|
+
outreachDraftReply: (backlinkId) =>
|
|
159
|
+
request("POST", "/api/v1/outreach/draft-reply", { backlinkId }),
|
|
160
|
+
contentIdeas: () => request("GET", "/api/v1/content/ideas"),
|
|
161
|
+
planIdea: (opts) => request("POST", "/api/v1/content/ideas/plan", opts),
|
|
162
|
+
archiveContent: (contentId) =>
|
|
163
|
+
request("POST", "/api/v1/content/archive", { contentId }),
|
|
164
|
+
pageEngagement: () => request("GET", "/api/v1/analytics/engagement"),
|
|
165
|
+
reportSummary: () => request("GET", "/api/v1/reports/summary"),
|
|
166
|
+
reportWins: () => request("GET", "/api/v1/reports/wins"),
|
|
167
|
+
agentActivity: (params = {}) => {
|
|
168
|
+
const q = new URLSearchParams();
|
|
169
|
+
if (params.perAgent) q.set("perAgent", String(params.perAgent));
|
|
170
|
+
if (params.sinceDays) q.set("sinceDays", String(params.sinceDays));
|
|
171
|
+
const qs = q.toString();
|
|
172
|
+
return request("GET", `/api/v1/reports/agent-activity${qs ? `?${qs}` : ""}`);
|
|
173
|
+
},
|
|
174
|
+
backlinks: (status) =>
|
|
175
|
+
request(
|
|
176
|
+
"GET",
|
|
177
|
+
`/api/v1/backlinks${status ? `?status=${encodeURIComponent(status)}` : ""}`
|
|
178
|
+
),
|
|
179
|
+
backlinkStats: () => request("GET", "/api/v1/backlinks/stats"),
|
|
180
|
+
outreachStatus: (backlinkId, status) =>
|
|
181
|
+
request("POST", "/api/v1/outreach/status", { backlinkId, status }),
|
|
182
|
+
linkNetwork: () => request("GET", "/api/v1/link-network"),
|
|
183
|
+
linkNetworkOptIn: (optIn, confirm = false) =>
|
|
184
|
+
request("POST", "/api/v1/link-network/opt-in", { optIn, confirm }),
|
|
185
|
+
linkNetworkRemovePlacement: (placementId, confirm = false) =>
|
|
186
|
+
request("POST", "/api/v1/link-network/remove-placement", {
|
|
187
|
+
placementId,
|
|
188
|
+
confirm,
|
|
189
|
+
}),
|
|
190
|
+
socialThreads: (params = {}) => {
|
|
191
|
+
const q = new URLSearchParams();
|
|
192
|
+
if (params.platform) q.set("platform", params.platform);
|
|
193
|
+
if (params.status) q.set("status", params.status);
|
|
194
|
+
if (params.age) q.set("age", params.age);
|
|
195
|
+
const qs = q.toString();
|
|
196
|
+
return request("GET", `/api/v1/social${qs ? `?${qs}` : ""}`);
|
|
197
|
+
},
|
|
198
|
+
socialStats: () => request("GET", "/api/v1/social/stats"),
|
|
199
|
+
socialStatus: (threadId, status) =>
|
|
200
|
+
request("POST", "/api/v1/social/status", { threadId, status }),
|
|
201
|
+
socialDraftReply: (threadId, mentionMode) =>
|
|
202
|
+
request("POST", "/api/v1/social/draft-reply", { threadId, mentionMode }),
|
|
203
|
+
support: (opts) => request("POST", "/api/v1/support", opts),
|
|
204
|
+
brand: () => request("GET", "/api/v1/brand"),
|
|
205
|
+
brandProfileSet: (opts) => request("POST", "/api/v1/brand/profile", opts),
|
|
206
|
+
brandProductWrite: (opts) => request("POST", "/api/v1/brand/product", opts),
|
|
207
|
+
brandIcpWrite: (opts) => request("POST", "/api/v1/brand/icp", opts),
|
|
58
208
|
};
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import {
|
|
4
|
+
getBaseUrls,
|
|
5
|
+
writeStoredConfig,
|
|
6
|
+
deleteStoredConfig,
|
|
7
|
+
} from "./client.mjs";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SCOPES = [
|
|
10
|
+
"read:citations",
|
|
11
|
+
"read:content",
|
|
12
|
+
"read:leads",
|
|
13
|
+
"read:analytics",
|
|
14
|
+
"write:content",
|
|
15
|
+
"write:publish",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function openBrowser(url) {
|
|
19
|
+
if (process.env.RANKCONTROL_NO_BROWSER) return false;
|
|
20
|
+
const cmd =
|
|
21
|
+
process.platform === "darwin"
|
|
22
|
+
? "open"
|
|
23
|
+
: process.platform === "win32"
|
|
24
|
+
? "start"
|
|
25
|
+
: "xdg-open";
|
|
26
|
+
try {
|
|
27
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function login({ scopes } = {}) {
|
|
35
|
+
const { baseUrl, appUrl } = getBaseUrls();
|
|
36
|
+
const requested = scopes?.length ? scopes : DEFAULT_SCOPES;
|
|
37
|
+
|
|
38
|
+
const startRes = await fetch(`${baseUrl}/api/v1/auth/device/start`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
scopes: requested,
|
|
43
|
+
clientName: `CLI on ${hostname()}`,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
const startJson = await startRes.json();
|
|
47
|
+
if (!startRes.ok) {
|
|
48
|
+
throw new Error(startJson?.error || `Login start failed (HTTP ${startRes.status})`);
|
|
49
|
+
}
|
|
50
|
+
const { deviceCode, userCode, verificationPath, pollIntervalSeconds } =
|
|
51
|
+
startJson.data;
|
|
52
|
+
|
|
53
|
+
const url = `${appUrl}${verificationPath}`;
|
|
54
|
+
console.log(`\nConfirmation code: ${userCode}`);
|
|
55
|
+
console.log(`Approve access in your browser: ${url}`);
|
|
56
|
+
console.log("(check that the code on the page matches the one above)\n");
|
|
57
|
+
openBrowser(url);
|
|
58
|
+
|
|
59
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
60
|
+
process.stdout.write("Waiting for approval");
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
await new Promise((r) => setTimeout(r, (pollIntervalSeconds || 3) * 1000));
|
|
63
|
+
process.stdout.write(".");
|
|
64
|
+
|
|
65
|
+
const pollRes = await fetch(`${baseUrl}/api/v1/auth/device/poll`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ deviceCode }),
|
|
69
|
+
}).catch(() => null);
|
|
70
|
+
if (!pollRes) continue;
|
|
71
|
+
const pollJson = await pollRes.json().catch(() => null);
|
|
72
|
+
const status = pollJson?.data?.status;
|
|
73
|
+
|
|
74
|
+
if (status === "pending") continue;
|
|
75
|
+
process.stdout.write("\n");
|
|
76
|
+
|
|
77
|
+
if (status === "complete") {
|
|
78
|
+
const file = writeStoredConfig({
|
|
79
|
+
apiKey: pollJson.data.apiKey,
|
|
80
|
+
keyPrefix: pollJson.data.keyPrefix,
|
|
81
|
+
orgName: pollJson.data.orgName,
|
|
82
|
+
...(process.env.RANKCONTROL_API_URL
|
|
83
|
+
? { baseUrl: process.env.RANKCONTROL_API_URL }
|
|
84
|
+
: {}),
|
|
85
|
+
...(process.env.RANKCONTROL_APP_URL
|
|
86
|
+
? { appUrl: process.env.RANKCONTROL_APP_URL }
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
console.log(`Logged in to ${pollJson.data.orgName} (key ${pollJson.data.keyPrefix}...).`);
|
|
90
|
+
console.log(`Credentials saved to ${file}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (status === "denied") throw new Error("Login request was denied in the browser.");
|
|
94
|
+
if (status === "expired") throw new Error("Login request expired. Run login again.");
|
|
95
|
+
throw new Error(pollJson?.error || `Unexpected login status: ${status}`);
|
|
96
|
+
}
|
|
97
|
+
process.stdout.write("\n");
|
|
98
|
+
throw new Error("Timed out waiting for approval. Run login again.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function logout() {
|
|
102
|
+
const removed = deleteStoredConfig();
|
|
103
|
+
if (removed) {
|
|
104
|
+
console.log("Local credentials removed.");
|
|
105
|
+
console.log(
|
|
106
|
+
"The key itself is still active: revoke it in RankControl → Settings → API if this machine should lose access permanently."
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
console.log("No stored credentials found.");
|
|
110
|
+
}
|
|
111
|
+
}
|