rankcontrol 0.2.0 → 0.7.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/LICENSE +1 -1
- package/README.md +182 -10
- package/package.json +1 -1
- package/src/cli.mjs +648 -5
- package/src/client.mjs +135 -0
- package/src/login.mjs +11 -2
- package/src/mcp.mjs +734 -2
package/src/cli.mjs
CHANGED
|
@@ -12,13 +12,16 @@ const fail = (err) => {
|
|
|
12
12
|
const list = (val) => val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
13
13
|
|
|
14
14
|
export function runCli(argv) {
|
|
15
|
+
const pkg = JSON.parse(
|
|
16
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
17
|
+
);
|
|
15
18
|
const program = new Command();
|
|
16
19
|
program
|
|
17
20
|
.name("rankcontrol")
|
|
18
21
|
.description(
|
|
19
22
|
"RankControl from the terminal. Auth: `rankcontrol login` (browser approval) or export RANKCONTROL_API_KEY=rctrl_pk_..."
|
|
20
23
|
)
|
|
21
|
-
.version(
|
|
24
|
+
.version(pkg.version);
|
|
22
25
|
|
|
23
26
|
program
|
|
24
27
|
.command("mcp")
|
|
@@ -30,11 +33,10 @@ export function runCli(argv) {
|
|
|
30
33
|
|
|
31
34
|
program
|
|
32
35
|
.command("login")
|
|
33
|
-
.description("Authenticate via your browser (
|
|
34
|
-
.
|
|
35
|
-
.action(async (opts) => {
|
|
36
|
+
.description("Authenticate via your browser (full workspace access; billing stays in the dashboard)")
|
|
37
|
+
.action(async () => {
|
|
36
38
|
const { login } = await import("./login.mjs");
|
|
37
|
-
await login(
|
|
39
|
+
await login().catch(fail);
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
program
|
|
@@ -50,12 +52,53 @@ export function runCli(argv) {
|
|
|
50
52
|
.description("AI pipeline last 30 days: crawls, AI visits, AI leads")
|
|
51
53
|
.action(() => api.overviewFunnel().then(out).catch(fail));
|
|
52
54
|
|
|
55
|
+
program
|
|
56
|
+
.command("leads")
|
|
57
|
+
.description("Captured leads with source attribution (AI model, query, page)")
|
|
58
|
+
.option("--limit <n>", "Max rows, up to 500", "100")
|
|
59
|
+
.action((opts) => api.leads(Number(opts.limit)).then(out).catch(fail));
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command("traffic")
|
|
63
|
+
.description("Traffic overview: page views, visitors, sessions, bounce rate, time on page")
|
|
64
|
+
.option("--days <n>", "7, 30 or 90", "30")
|
|
65
|
+
.action((opts) => api.trafficOverview(Number(opts.days)).then(out).catch(fail));
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command("score")
|
|
69
|
+
.description("Composite visibility score: 50% AI citation rate + 30% Google + 20% Bing rank share, with subscores")
|
|
70
|
+
.action(() => api.visibilityScore().then(out).catch(fail));
|
|
71
|
+
|
|
53
72
|
program
|
|
54
73
|
.command("visibility")
|
|
55
74
|
.description("Daily AI visibility score trend from the stored weekly citation checks")
|
|
56
75
|
.option("--days <n>", "30, 60 or 90", "30")
|
|
57
76
|
.action((opts) => api.visibilityTrend(Number(opts.days)).then(out).catch(fail));
|
|
58
77
|
|
|
78
|
+
program
|
|
79
|
+
.command("sov")
|
|
80
|
+
.description("Share of voice: your citation rate vs top competitors, plus your share of all brand appearances")
|
|
81
|
+
.option("--days <n>", "Window in days, 7-90", "30")
|
|
82
|
+
.action((opts) => api.shareOfVoice(Number(opts.days)).then(out).catch(fail));
|
|
83
|
+
|
|
84
|
+
program
|
|
85
|
+
.command("sources")
|
|
86
|
+
.description("Domains AI answers cite for your tracked queries, typed brand/competitive/ugc/editorial")
|
|
87
|
+
.option("--days <n>", "Window in days, 7-90", "30")
|
|
88
|
+
.action((opts) => api.citationSources(Number(opts.days)).then(out).catch(fail));
|
|
89
|
+
|
|
90
|
+
program
|
|
91
|
+
.command("sentiment")
|
|
92
|
+
.description("How AI answers frame your brand when cited: positive/neutral/negative with recent receipts")
|
|
93
|
+
.option("--days <n>", "Window in days, 7-90", "30")
|
|
94
|
+
.action((opts) => api.citationSentiment(Number(opts.days)).then(out).catch(fail));
|
|
95
|
+
|
|
96
|
+
program
|
|
97
|
+
.command("optimizer")
|
|
98
|
+
.description("Published pages ranked by citability score, worst first, with their open fixes")
|
|
99
|
+
.option("--limit <n>", "Max rows", "25")
|
|
100
|
+
.action((opts) => api.optimizer(Number(opts.limit)).then(out).catch(fail));
|
|
101
|
+
|
|
59
102
|
program
|
|
60
103
|
.command("citations")
|
|
61
104
|
.description("Recent AI citation checks")
|
|
@@ -68,11 +111,254 @@ export function runCli(argv) {
|
|
|
68
111
|
.catch(fail)
|
|
69
112
|
);
|
|
70
113
|
|
|
114
|
+
program
|
|
115
|
+
.command("queries")
|
|
116
|
+
.description("List the tracked queries checked weekly across the AI engines")
|
|
117
|
+
.action(() => api.trackedQueries().then(out).catch(fail));
|
|
118
|
+
|
|
119
|
+
program
|
|
120
|
+
.command("crawler-access")
|
|
121
|
+
.description("Daily AI-crawler reachability probe (a diagnostic, not a metric): is the site's edge or robots.txt blocking GPTBot/ClaudeBot")
|
|
122
|
+
.action(() => api.crawlerAccess().then(out).catch(fail));
|
|
123
|
+
|
|
124
|
+
program
|
|
125
|
+
.command("analytics-sources")
|
|
126
|
+
.description("Per-org analytics sources: selections, qualified options, Cloudflare/plugin/embed install state")
|
|
127
|
+
.action(() => api.analyticsSources().then(out).catch(fail));
|
|
128
|
+
|
|
129
|
+
program
|
|
130
|
+
.command("analytics-set-source")
|
|
131
|
+
.description("Select the writer for a data type (validated against what the setup supports)")
|
|
132
|
+
.argument("<dataType>", "humanTraffic or aiCrawlers")
|
|
133
|
+
.argument("<source>", "embed, wordpress_plugin, cloudflare or none")
|
|
134
|
+
.action((dataType, source) =>
|
|
135
|
+
api.setAnalyticsSource(dataType, source).then(out).catch(fail)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
program
|
|
139
|
+
.command("analytics-activate")
|
|
140
|
+
.description("Turn on the Analytics or Reports screen for this workspace")
|
|
141
|
+
.argument("<screen>", "analytics or reports")
|
|
142
|
+
.action((screen) => api.activateScreen(screen).then(out).catch(fail));
|
|
143
|
+
|
|
144
|
+
program
|
|
145
|
+
.command("cloudflare-connect")
|
|
146
|
+
.description("Print the Cloudflare OAuth URL to connect read-only crawler analytics")
|
|
147
|
+
.action(() =>
|
|
148
|
+
api
|
|
149
|
+
.cloudflareConnectUrl()
|
|
150
|
+
.then((d) => out(d?.url ?? d))
|
|
151
|
+
.catch(fail)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
program
|
|
155
|
+
.command("cloudflare-zones")
|
|
156
|
+
.description("List Cloudflare zones visible to the connected grant")
|
|
157
|
+
.action(() => api.cloudflareZones().then(out).catch(fail));
|
|
158
|
+
|
|
159
|
+
program
|
|
160
|
+
.command("cloudflare-zone")
|
|
161
|
+
.description("Pick the polled zone (also selects Cloudflare as the AI-crawler source and starts the first sync)")
|
|
162
|
+
.argument("<zoneId>", "Zone id from cloudflare-zones")
|
|
163
|
+
.argument("<zoneName>", "Zone name, e.g. example.com")
|
|
164
|
+
.action((zoneId, zoneName) =>
|
|
165
|
+
api.cloudflareSelectZone(zoneId, zoneName).then(out).catch(fail)
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
program
|
|
169
|
+
.command("framer-install-embed")
|
|
170
|
+
.description("Install site-wide tracking on the Framer project (dry run without --confirm; installing also publishes the Framer site)")
|
|
171
|
+
.option("--integration <id>", "Integration id (defaults to the org's Framer integration)")
|
|
172
|
+
.option("--confirm", "Actually install", false)
|
|
173
|
+
.action((opts) =>
|
|
174
|
+
api
|
|
175
|
+
.framerInstallEmbed({
|
|
176
|
+
...(opts.integration ? { integrationId: opts.integration } : {}),
|
|
177
|
+
confirm: Boolean(opts.confirm),
|
|
178
|
+
})
|
|
179
|
+
.then(out)
|
|
180
|
+
.catch(fail)
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
program
|
|
184
|
+
.command("webflow-custom-code")
|
|
185
|
+
.description("Install the tracking loader via Webflow's Custom Code API (dry run without --confirm; live on the customer's next site publish)")
|
|
186
|
+
.option("--integration <id>", "Integration id (defaults to the org's Webflow integration)")
|
|
187
|
+
.option("--token <token>", "New site token with CMS + Custom code scopes (replaces the stored one)")
|
|
188
|
+
.option("--confirm", "Actually install", false)
|
|
189
|
+
.action((opts) =>
|
|
190
|
+
api
|
|
191
|
+
.webflowInstallCustomCode({
|
|
192
|
+
...(opts.integration ? { integrationId: opts.integration } : {}),
|
|
193
|
+
...(opts.token ? { apiToken: opts.token } : {}),
|
|
194
|
+
confirm: Boolean(opts.confirm),
|
|
195
|
+
})
|
|
196
|
+
.then(out)
|
|
197
|
+
.catch(fail)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
program
|
|
201
|
+
.command("query-add")
|
|
202
|
+
.description("Add a query to the tracking pool (tracks immediately when a plan slot is free)")
|
|
203
|
+
.argument("<queryText>", "The search/AI query to track")
|
|
204
|
+
.action((queryText) => api.addQuery(queryText).then(out).catch(fail));
|
|
205
|
+
|
|
206
|
+
program
|
|
207
|
+
.command("query-remove")
|
|
208
|
+
.description("Delete a query from the tracking pool")
|
|
209
|
+
.argument("<queryId>", "Query id (from the queries command)")
|
|
210
|
+
.action((queryId) => api.removeQuery(queryId).then(out).catch(fail));
|
|
211
|
+
|
|
212
|
+
program
|
|
213
|
+
.command("query-track")
|
|
214
|
+
.description("Pause or resume weekly checks on a query. Tracking uses a plan slot; pausing frees it")
|
|
215
|
+
.argument("<queryId>", "Query id (from the queries command)")
|
|
216
|
+
.argument("<state>", "on | off")
|
|
217
|
+
.action((queryId, state) =>
|
|
218
|
+
api.setQueryTracking(queryId, state === "on").then(out).catch(fail)
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
program
|
|
222
|
+
.command("query-edit")
|
|
223
|
+
.description("Rewrite a tracked query's text")
|
|
224
|
+
.argument("<queryId>", "Query id (from the queries command)")
|
|
225
|
+
.argument("<queryText>", "New query text")
|
|
226
|
+
.action((queryId, queryText) =>
|
|
227
|
+
api.updateQuery(queryId, queryText).then(out).catch(fail)
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
program
|
|
231
|
+
.command("competitors")
|
|
232
|
+
.description("Tracked competitors used in share-of-voice and visibility comparisons")
|
|
233
|
+
.action(() => api.competitors().then(out).catch(fail));
|
|
234
|
+
|
|
235
|
+
program
|
|
236
|
+
.command("competitor-add")
|
|
237
|
+
.description("Track a competitor (max 10)")
|
|
238
|
+
.argument("<name>", "Competitor name")
|
|
239
|
+
.argument("<websiteUrl>", "Competitor website URL")
|
|
240
|
+
.action((name, websiteUrl) =>
|
|
241
|
+
api.addCompetitor(name, websiteUrl).then(out).catch(fail)
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
program
|
|
245
|
+
.command("competitor-remove")
|
|
246
|
+
.description("Stop tracking a competitor")
|
|
247
|
+
.argument("<competitorId>", "Competitor id (from the competitors command)")
|
|
248
|
+
.action((competitorId) =>
|
|
249
|
+
api.removeCompetitor(competitorId).then(out).catch(fail)
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
program
|
|
253
|
+
.command("topics")
|
|
254
|
+
.description("The pillar list (topic clusters): queries, ideas and planned articles group under these")
|
|
255
|
+
.action(() => api.topics().then(out).catch(fail));
|
|
256
|
+
|
|
257
|
+
program
|
|
258
|
+
.command("topics-set")
|
|
259
|
+
.description("Replace the full pillar list (create/dedupe/safe-delete reconcile)")
|
|
260
|
+
.argument("<topics...>", "Topic names, space-separated (quote multi-word topics)")
|
|
261
|
+
.action((topics) => api.setTopics(topics).then(out).catch(fail));
|
|
262
|
+
|
|
263
|
+
program
|
|
264
|
+
.command("delete-planned")
|
|
265
|
+
.description("Remove a planned title from the calendar; the title returns to Content Ideas")
|
|
266
|
+
.argument("<contentId>", "Planned contentPage id")
|
|
267
|
+
.action((contentId) => api.deletePlanned(contentId).then(out).catch(fail));
|
|
268
|
+
|
|
71
269
|
program
|
|
72
270
|
.command("capacity")
|
|
73
271
|
.description("Remaining plan slots on the content calendar")
|
|
74
272
|
.action(() => api.planningCapacity().then(out).catch(fail));
|
|
75
273
|
|
|
274
|
+
program
|
|
275
|
+
.command("team")
|
|
276
|
+
.description("Workspace members and pending invites (owner surface)")
|
|
277
|
+
.action(() => api.team().then(out).catch(fail));
|
|
278
|
+
|
|
279
|
+
program
|
|
280
|
+
.command("team-invite")
|
|
281
|
+
.description("Invite a member (dry-run by default; --confirm sends the email)")
|
|
282
|
+
.argument("<email>", "Invitee email")
|
|
283
|
+
.option(
|
|
284
|
+
"--perms <area=level,...>",
|
|
285
|
+
"Per-screen levels, e.g. content=write,analytics=read (unspecified screens default to none)"
|
|
286
|
+
)
|
|
287
|
+
.option("--confirm", "Actually send the invite")
|
|
288
|
+
.action((email, opts) => {
|
|
289
|
+
const screenPermissions = {};
|
|
290
|
+
for (const pair of (opts.perms ?? "").split(",").filter(Boolean)) {
|
|
291
|
+
const [area, level] = pair.split("=");
|
|
292
|
+
screenPermissions[area?.trim()] = level?.trim();
|
|
293
|
+
}
|
|
294
|
+
return api
|
|
295
|
+
.teamInvite({
|
|
296
|
+
email,
|
|
297
|
+
...(Object.keys(screenPermissions).length ? { screenPermissions } : {}),
|
|
298
|
+
confirm: !!opts.confirm,
|
|
299
|
+
})
|
|
300
|
+
.then(out)
|
|
301
|
+
.catch(fail);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
program
|
|
305
|
+
.command("team-revoke")
|
|
306
|
+
.description("Revoke a pending invite")
|
|
307
|
+
.argument("<invitationId>", "Invitation id from `team`")
|
|
308
|
+
.action((invitationId) => api.teamRevoke(invitationId).then(out).catch(fail));
|
|
309
|
+
|
|
310
|
+
program
|
|
311
|
+
.command("team-remove")
|
|
312
|
+
.description("Remove a member (requires --confirm)")
|
|
313
|
+
.argument("<userId>", "Member user id from `team`")
|
|
314
|
+
.option("--confirm", "Actually remove the member")
|
|
315
|
+
.action((userId, opts) =>
|
|
316
|
+
api
|
|
317
|
+
.teamRemove({ userId, confirm: !!opts.confirm })
|
|
318
|
+
.then(out)
|
|
319
|
+
.catch(fail)
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
program
|
|
323
|
+
.command("outreach-prospects")
|
|
324
|
+
.description("Link outreach pipeline with any found contact emails")
|
|
325
|
+
.action(() => api.outreachProspects().then(out).catch(fail));
|
|
326
|
+
|
|
327
|
+
program
|
|
328
|
+
.command("outreach-find-contact")
|
|
329
|
+
.description("Find an outreach email for a prospect (scrapes their site)")
|
|
330
|
+
.argument("<backlinkId>", "Prospect id from `outreach-prospects`")
|
|
331
|
+
.action((backlinkId) =>
|
|
332
|
+
api.outreachFindContact(backlinkId).then(out).catch(fail)
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
program
|
|
336
|
+
.command("outreach-draft-reply")
|
|
337
|
+
.description("AI-draft a reply to a prospect's inbound response (nothing sends)")
|
|
338
|
+
.argument("<backlinkId>", "Prospect id from `outreach-prospects`")
|
|
339
|
+
.action((backlinkId) =>
|
|
340
|
+
api.outreachDraftReply(backlinkId).then(out).catch(fail)
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
program
|
|
344
|
+
.command("outreach-queue")
|
|
345
|
+
.description("Queue an outreach email to send from the connected mailbox (dry-run by default)")
|
|
346
|
+
.argument("<backlinkId>", "Prospect id from `outreach-prospects`")
|
|
347
|
+
.option("--subject <subject>", "Override the drafted subject")
|
|
348
|
+
.option("--body <body>", "Override the drafted body")
|
|
349
|
+
.option("--confirm", "Actually queue the send")
|
|
350
|
+
.action((backlinkId, opts) =>
|
|
351
|
+
api
|
|
352
|
+
.outreachQueue({
|
|
353
|
+
backlinkId,
|
|
354
|
+
...(opts.subject ? { subject: opts.subject } : {}),
|
|
355
|
+
...(opts.body ? { body: opts.body } : {}),
|
|
356
|
+
confirm: !!opts.confirm,
|
|
357
|
+
})
|
|
358
|
+
.then(out)
|
|
359
|
+
.catch(fail)
|
|
360
|
+
);
|
|
361
|
+
|
|
76
362
|
program
|
|
77
363
|
.command("jobs")
|
|
78
364
|
.description("Recent agent runs (async job status)")
|
|
@@ -131,5 +417,362 @@ export function runCli(argv) {
|
|
|
131
417
|
api.publishContent(contentId, !!opts.confirm).then(out).catch(fail)
|
|
132
418
|
);
|
|
133
419
|
|
|
420
|
+
program
|
|
421
|
+
.command("generate <contentId>")
|
|
422
|
+
.description("Write a planned article's body now, keeping its publish slot (past-due or ≤3 days ahead; dry run unless --confirm)")
|
|
423
|
+
.option("--confirm", "Actually start generation (spends LLM budget)")
|
|
424
|
+
.action((contentId, opts) =>
|
|
425
|
+
api.generateContent(contentId, !!opts.confirm).then(out).catch(fail)
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
program
|
|
429
|
+
.command("shopify-install-url <shop>")
|
|
430
|
+
.description("Mint a Shopify app install link for your store (open it in a browser; the destination is added to the workspace automatically)")
|
|
431
|
+
.action((shop) => api.shopifyInstallUrl(shop).then(out).catch(fail));
|
|
432
|
+
|
|
433
|
+
program
|
|
434
|
+
.command("settings")
|
|
435
|
+
.description("Show the article policy: per-article defaults + flexible scheduling")
|
|
436
|
+
.action(() => api.articleSettings().then(out).catch(fail));
|
|
437
|
+
|
|
438
|
+
const onOff = (val) => {
|
|
439
|
+
if (val === "on" || val === "true") return true;
|
|
440
|
+
if (val === "off" || val === "false") return false;
|
|
441
|
+
throw new Error(`Expected on|off, got "${val}"`);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
program
|
|
445
|
+
.command("settings-set")
|
|
446
|
+
.description("Merge-patch the article policy (only flags you pass change)")
|
|
447
|
+
.option("--auto-publish <on|off>")
|
|
448
|
+
.option("--auto-generate <on|off>", "Off = fully manual: write articles only on demand (generate command / Write now)")
|
|
449
|
+
.option("--images <on|off>", "AI hero + section images")
|
|
450
|
+
.option("--title-in-hero <on|off>", "Write the post title into the hero image")
|
|
451
|
+
.option("--section-infographics <on|off>", "Informational panels in 2,000+ word articles")
|
|
452
|
+
.option("--related-reading <on|off>", "Related Reading link block at the end")
|
|
453
|
+
.option("--youtube <on|off>", "Embedded video suggestions")
|
|
454
|
+
.option("--emojis <on|off>")
|
|
455
|
+
.option("--internal-links <n>", "Internal links per article (1-20)")
|
|
456
|
+
.option("--external-links <n>", "Outbound authority links per article (0-15)")
|
|
457
|
+
.option("--instructions <text>", "Global editorial instructions")
|
|
458
|
+
.option("--flexible-schedule <on|off>", "1-5 articles/day + calendar drag-and-drop")
|
|
459
|
+
.option(
|
|
460
|
+
"--image-style <set>",
|
|
461
|
+
"Image style set for all generated images: classic-editorial | print-craft | storybook-painterly | modern-saas | dark-premium | bold-poster"
|
|
462
|
+
)
|
|
463
|
+
.action((opts) => {
|
|
464
|
+
try {
|
|
465
|
+
const s = {};
|
|
466
|
+
if (opts.autoPublish !== undefined) s.autoPublish = onOff(opts.autoPublish);
|
|
467
|
+
if (opts.autoGenerate !== undefined) s.autoGenerate = onOff(opts.autoGenerate);
|
|
468
|
+
if (opts.images !== undefined) s.includeInfographics = onOff(opts.images);
|
|
469
|
+
if (opts.titleInHero !== undefined) s.titleInHeroImage = onOff(opts.titleInHero);
|
|
470
|
+
if (opts.sectionInfographics !== undefined)
|
|
471
|
+
s.includeSectionInfographics = onOff(opts.sectionInfographics);
|
|
472
|
+
if (opts.relatedReading !== undefined)
|
|
473
|
+
s.includeRelatedReading = onOff(opts.relatedReading);
|
|
474
|
+
if (opts.youtube !== undefined) s.includeYouTube = onOff(opts.youtube);
|
|
475
|
+
if (opts.emojis !== undefined) s.useEmojis = onOff(opts.emojis);
|
|
476
|
+
if (opts.internalLinks !== undefined)
|
|
477
|
+
s.internalLinksPerArticle = Number(opts.internalLinks);
|
|
478
|
+
if (opts.externalLinks !== undefined)
|
|
479
|
+
s.externalLinksPerArticle = Number(opts.externalLinks);
|
|
480
|
+
if (opts.instructions !== undefined) s.globalInstructions = opts.instructions;
|
|
481
|
+
const body = {};
|
|
482
|
+
if (Object.keys(s).length > 0) body.articleSettings = s;
|
|
483
|
+
if (opts.flexibleSchedule !== undefined)
|
|
484
|
+
body.flexibleScheduling = onOff(opts.flexibleSchedule);
|
|
485
|
+
if (opts.imageStyle !== undefined) body.styleSet = opts.imageStyle;
|
|
486
|
+
if (Object.keys(body).length === 0)
|
|
487
|
+
throw new Error("Pass at least one flag to change (see --help)");
|
|
488
|
+
return api.updateArticleSettings(body).then(out).catch(fail);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
fail(err);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
program
|
|
495
|
+
.command("reschedule <contentId> <date>")
|
|
496
|
+
.description("Move a planned article to a day (YYYY-MM-DD, local time; needs the flexible schedule)")
|
|
497
|
+
.action((contentId, date) => {
|
|
498
|
+
const day = new Date(`${date}T00:00:00`);
|
|
499
|
+
if (Number.isNaN(day.getTime())) return fail(new Error("Date must be YYYY-MM-DD"));
|
|
500
|
+
return api.reschedule(contentId, day.getTime()).then(out).catch(fail);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
program
|
|
504
|
+
.command("internal-links <contentId>")
|
|
505
|
+
.description("Inbound and outbound internal links recorded for an article (from the publish-time link graph)")
|
|
506
|
+
.action((contentId) => api.internalLinks(contentId).then(out).catch(fail));
|
|
507
|
+
|
|
508
|
+
program
|
|
509
|
+
.command("site-pages")
|
|
510
|
+
.description("List the site pages used for in-article links and Related Reading")
|
|
511
|
+
.action(() => api.sitePages().then(out).catch(fail));
|
|
512
|
+
|
|
513
|
+
program
|
|
514
|
+
.command("detect-links <url>")
|
|
515
|
+
.description("Scan a sitemap (nested indexes too) or a blog root page for site pages")
|
|
516
|
+
.option("--blog-root", "Treat the URL as a page to crawl instead of a sitemap")
|
|
517
|
+
.action((url, opts) =>
|
|
518
|
+
api
|
|
519
|
+
.detectSiteLinks(opts.blogRoot ? "blogroot" : "sitemap", url)
|
|
520
|
+
.then(out)
|
|
521
|
+
.catch(fail)
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
program
|
|
525
|
+
.command("add-pages <urls...>")
|
|
526
|
+
.description("Add site page URLs for internal linking (dedupes; titles auto-fill)")
|
|
527
|
+
.action((urls) => api.addSitePages(urls).then(out).catch(fail));
|
|
528
|
+
|
|
529
|
+
program
|
|
530
|
+
.command("repurpose [contentId]")
|
|
531
|
+
.description("Repurpose queue (published articles + draft chips), or full drafts for one article")
|
|
532
|
+
.action((contentId) =>
|
|
533
|
+
(contentId ? api.repurposeDrafts(contentId) : api.repurposeQueue())
|
|
534
|
+
.then(out)
|
|
535
|
+
.catch(fail)
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
program
|
|
539
|
+
.command("repurpose-generate <contentId>")
|
|
540
|
+
.description("Draft social posts for a published article (dry run unless --confirm; spends LLM budget)")
|
|
541
|
+
.option(
|
|
542
|
+
"--platforms <a,b>",
|
|
543
|
+
"Subset: linkedin,twitter,pinterest,instagram,facebook,threads,youtube,tiktok"
|
|
544
|
+
)
|
|
545
|
+
.option("--confirm", "Actually generate")
|
|
546
|
+
.action((contentId, opts) =>
|
|
547
|
+
api
|
|
548
|
+
.repurposeGenerate({
|
|
549
|
+
contentId,
|
|
550
|
+
platforms: opts.platforms ? list(opts.platforms) : undefined,
|
|
551
|
+
confirm: !!opts.confirm,
|
|
552
|
+
})
|
|
553
|
+
.then(out)
|
|
554
|
+
.catch(fail)
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
program
|
|
558
|
+
.command("repurpose-edit <draftId>")
|
|
559
|
+
.description("Edit a repurpose draft's text/title before pushing")
|
|
560
|
+
.option("--body <text>")
|
|
561
|
+
.option("--title <text>")
|
|
562
|
+
.action((draftId, opts) =>
|
|
563
|
+
api
|
|
564
|
+
.repurposeEditDraft({ draftId, body: opts.body, title: opts.title })
|
|
565
|
+
.then(out)
|
|
566
|
+
.catch(fail)
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
program
|
|
570
|
+
.command("repurpose-mark-posted <draftId>")
|
|
571
|
+
.description("Mark a draft as posted (you published it yourself)")
|
|
572
|
+
.action((draftId) => api.repurposeMarkPosted(draftId).then(out).catch(fail));
|
|
573
|
+
|
|
574
|
+
program
|
|
575
|
+
.command("repurpose-channels")
|
|
576
|
+
.description("Connected Postiz channels (ids needed for repurpose-push)")
|
|
577
|
+
.action(() => api.repurposeChannels().then(out).catch(fail));
|
|
578
|
+
|
|
579
|
+
program
|
|
580
|
+
.command("repurpose-push <draftId>")
|
|
581
|
+
.description("Send a draft to Postiz (dry run unless --confirm)")
|
|
582
|
+
.option("--channels <id1,id2>", "Postiz channel ids (see repurpose-channels)")
|
|
583
|
+
.option("--when <now|schedule|draft>", "Postiz mode", "now")
|
|
584
|
+
.option("--date <iso>", "ISO time for --when schedule")
|
|
585
|
+
.option("--confirm", "Actually push (publicly visible)")
|
|
586
|
+
.action((draftId, opts) =>
|
|
587
|
+
api
|
|
588
|
+
.repurposePush({
|
|
589
|
+
draftId,
|
|
590
|
+
integrationIds: opts.channels ? list(opts.channels) : undefined,
|
|
591
|
+
scheduleType: opts.when,
|
|
592
|
+
date: opts.date,
|
|
593
|
+
confirm: !!opts.confirm,
|
|
594
|
+
})
|
|
595
|
+
.then(out)
|
|
596
|
+
.catch(fail)
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
const parseJson = (raw, label) => {
|
|
600
|
+
try {
|
|
601
|
+
return JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
|
|
602
|
+
} catch {
|
|
603
|
+
fail(new Error(`--json for ${label} must be valid JSON (or @file.json)`));
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
program
|
|
608
|
+
.command("ideas")
|
|
609
|
+
.description("Scored content-idea backlog: uncovered queries, citation gaps, quick wins")
|
|
610
|
+
.action(() => api.contentIdeas().then(out).catch(fail));
|
|
611
|
+
|
|
612
|
+
program
|
|
613
|
+
.command("plan-idea <queryText>")
|
|
614
|
+
.description("Put an idea on the content calendar (uses its stored title, or generates one)")
|
|
615
|
+
.option("--title <title>", "Exact headline to plan")
|
|
616
|
+
.action((queryText, opts) =>
|
|
617
|
+
api.planIdea({ queryText, title: opts.title }).then(out).catch(fail)
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
program
|
|
621
|
+
.command("archive <contentId>")
|
|
622
|
+
.description("Archive an article (removes it from the working set)")
|
|
623
|
+
.action((contentId) => api.archiveContent(contentId).then(out).catch(fail));
|
|
624
|
+
|
|
625
|
+
program
|
|
626
|
+
.command("engagement")
|
|
627
|
+
.description("Per-page views + citations for the last 30 days, with view sparklines")
|
|
628
|
+
.action(() => api.pageEngagement().then(out).catch(fail));
|
|
629
|
+
|
|
630
|
+
program
|
|
631
|
+
.command("report")
|
|
632
|
+
.description("Executive summary: last 30 days vs the 30 before (citations, views, crawls, publishing)")
|
|
633
|
+
.action(() => api.reportSummary().then(out).catch(fail));
|
|
634
|
+
|
|
635
|
+
program
|
|
636
|
+
.command("wins")
|
|
637
|
+
.description("Biggest wins of the last 30 days: top cited page, cite-rate jump, ranking climb, best backlink")
|
|
638
|
+
.action(() => api.reportWins().then(out).catch(fail));
|
|
639
|
+
|
|
640
|
+
program
|
|
641
|
+
.command("agent-activity")
|
|
642
|
+
.description("Recent runs per agent lane (brand control, radar, forge, deploy, ...)")
|
|
643
|
+
.option("--per-agent <n>", "Runs per lane, up to 50", "15")
|
|
644
|
+
.option("--since-days <n>", "Window in days", "30")
|
|
645
|
+
.action((opts) =>
|
|
646
|
+
api
|
|
647
|
+
.agentActivity({ perAgent: Number(opts.perAgent), sinceDays: Number(opts.sinceDays) })
|
|
648
|
+
.then(out)
|
|
649
|
+
.catch(fail)
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
program
|
|
653
|
+
.command("backlinks")
|
|
654
|
+
.description("The backlink table, newest first")
|
|
655
|
+
.option("--status <status>", "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected")
|
|
656
|
+
.action((opts) => api.backlinks(opts.status).then(out).catch(fail));
|
|
657
|
+
|
|
658
|
+
program
|
|
659
|
+
.command("backlink-stats")
|
|
660
|
+
.description("Backlink totals + the outreach pipeline counts")
|
|
661
|
+
.action(() => api.backlinkStats().then(out).catch(fail));
|
|
662
|
+
|
|
663
|
+
program
|
|
664
|
+
.command("outreach-status <backlinkId> <status>")
|
|
665
|
+
.description("Move an outreach prospect through the pipeline (identified|contacted|replied|link_placed|rejected)")
|
|
666
|
+
.action((backlinkId, status) =>
|
|
667
|
+
api.outreachStatus(backlinkId, status).then(out).catch(fail)
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
program
|
|
671
|
+
.command("network")
|
|
672
|
+
.description("Link Network credit stats, membership state, and hosted/received placements")
|
|
673
|
+
.action(() => api.linkNetwork().then(out).catch(fail));
|
|
674
|
+
|
|
675
|
+
program
|
|
676
|
+
.command("network-opt-in <on|off>")
|
|
677
|
+
.description("Join or leave the Link Network (dry run unless --confirm; leaving retires live links)")
|
|
678
|
+
.option("--confirm", "Actually change membership")
|
|
679
|
+
.action((state, opts) =>
|
|
680
|
+
api.linkNetworkOptIn(state === "on", !!opts.confirm).then(out).catch(fail)
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
program
|
|
684
|
+
.command("network-remove-placement <placementId>")
|
|
685
|
+
.description("Retire one network placement (dry run unless --confirm; visible on the partner site)")
|
|
686
|
+
.option("--confirm", "Actually remove it")
|
|
687
|
+
.action((placementId, opts) =>
|
|
688
|
+
api.linkNetworkRemovePlacement(placementId, !!opts.confirm).then(out).catch(fail)
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
program
|
|
692
|
+
.command("social")
|
|
693
|
+
.description("Social thread prospects (Reddit/X) with rules context and intent sort")
|
|
694
|
+
.option("--platform <reddit|twitter>")
|
|
695
|
+
.option("--status <status>")
|
|
696
|
+
.option("--age <ranked|new>")
|
|
697
|
+
.action((opts) =>
|
|
698
|
+
api
|
|
699
|
+
.socialThreads({ platform: opts.platform, status: opts.status, age: opts.age })
|
|
700
|
+
.then(out)
|
|
701
|
+
.catch(fail)
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
program
|
|
705
|
+
.command("social-stats")
|
|
706
|
+
.description("Social thread pipeline counts")
|
|
707
|
+
.action(() => api.socialStats().then(out).catch(fail));
|
|
708
|
+
|
|
709
|
+
program
|
|
710
|
+
.command("social-status <threadId> <status>")
|
|
711
|
+
.description("Move a social thread through the pipeline")
|
|
712
|
+
.action((threadId, status) =>
|
|
713
|
+
api.socialStatus(threadId, status).then(out).catch(fail)
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
program
|
|
717
|
+
.command("social-draft-reply <threadId>")
|
|
718
|
+
.description("AI-draft a reply for a thread (nothing posts; the draft lands for review)")
|
|
719
|
+
.option("--mention <none|natural|founderOpen>", "Brand-mention mode")
|
|
720
|
+
.action((threadId, opts) =>
|
|
721
|
+
api.socialDraftReply(threadId, opts.mention).then(out).catch(fail)
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
program
|
|
725
|
+
.command("support <subject>")
|
|
726
|
+
.description("Send a message or bug report to RankControl support (emails the team, capped 3/min)")
|
|
727
|
+
.requiredOption("--message <text>", "What happened — include errors, ids, and steps")
|
|
728
|
+
.option("--page <url>", "Related dashboard or API URL")
|
|
729
|
+
.action((subject, opts) =>
|
|
730
|
+
api.support({ subject, message: opts.message, pageUrl: opts.page }).then(out).catch(fail)
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
program
|
|
734
|
+
.command("brand")
|
|
735
|
+
.description("Brand profile, products, and buyer profiles (ICPs) in one read")
|
|
736
|
+
.action(() => api.brand().then(out).catch(fail));
|
|
737
|
+
|
|
738
|
+
program
|
|
739
|
+
.command("brand-set")
|
|
740
|
+
.description("Update brand identity: name, industry, product description, authors, style reference URLs")
|
|
741
|
+
.option("--name <name>", "Company/brand name")
|
|
742
|
+
.option("--industry <industry>", "Industry label")
|
|
743
|
+
.option("--description <text>", "Product description")
|
|
744
|
+
.option("--json <json>", "Full patch as JSON (or @file.json): {name?, industry?, productDescription?, authors?, styleReferenceUrls?}; authors/styleReferenceUrls replace the stored list")
|
|
745
|
+
.action((opts) => {
|
|
746
|
+
const patch = opts.json ? parseJson(opts.json, "brand-set") : {};
|
|
747
|
+
if (opts.name) patch.name = opts.name;
|
|
748
|
+
if (opts.industry) patch.industry = opts.industry;
|
|
749
|
+
if (opts.description) patch.productDescription = opts.description;
|
|
750
|
+
api.brandIdentitySet(patch).then(out).catch(fail);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
program
|
|
754
|
+
.command("brand-profile-set")
|
|
755
|
+
.description("Patch brand voice/style fields. --json '{\"tone\":\"...\"}' or --json @file.json")
|
|
756
|
+
.requiredOption("--json <json>", "Fields: tone, primaryColor, secondaryColor, fontFamily, metaTitle, metaDescription, metaKeywords, defaultLocale, flexibleScheduling")
|
|
757
|
+
.action((opts) =>
|
|
758
|
+
api.brandProfileSet(parseJson(opts.json, "brand-profile-set")).then(out).catch(fail)
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
program
|
|
762
|
+
.command("brand-product")
|
|
763
|
+
.description("Create, update, or delete a product. Create: name+description+category; update: productId + fields; delete: productId + del:true")
|
|
764
|
+
.requiredOption("--json <json>", "Product JSON (or @file.json)")
|
|
765
|
+
.action((opts) =>
|
|
766
|
+
api.brandProductWrite(parseJson(opts.json, "brand-product")).then(out).catch(fail)
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
program
|
|
770
|
+
.command("brand-icp")
|
|
771
|
+
.description("Create, update, or delete a buyer profile. Create: title+industry+demographics; update: profileId + fields; delete: profileId + del:true")
|
|
772
|
+
.requiredOption("--json <json>", "Buyer profile JSON (or @file.json)")
|
|
773
|
+
.action((opts) =>
|
|
774
|
+
api.brandIcpWrite(parseJson(opts.json, "brand-icp")).then(out).catch(fail)
|
|
775
|
+
);
|
|
776
|
+
|
|
134
777
|
program.parseAsync(argv);
|
|
135
778
|
}
|