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/README.md
CHANGED
|
@@ -9,25 +9,58 @@ Every command calls the same authed, rate-limited, org-scoped API the RankContro
|
|
|
9
9
|
|
|
10
10
|
## Auth
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
The easy way — approve in your browser (you must be logged in to RankControl):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx rankcontrol login
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That opens your dashboard, you check the confirmation code matches, pick the permissions to grant, and the CLI stores a scoped key in `~/.rankcontrol/config.json`. `npx rankcontrol logout` removes it (revoke the key itself in Settings → API).
|
|
19
|
+
|
|
20
|
+
The headless way (CI, servers, MCP `env` blocks) — create a key in **RankControl → Settings → API**, then:
|
|
13
21
|
|
|
14
22
|
```bash
|
|
15
23
|
export RANKCONTROL_API_KEY=rctrl_pk_...
|
|
16
24
|
```
|
|
17
25
|
|
|
18
|
-
Scopes are set per key
|
|
26
|
+
The env var wins over the stored config when both exist. Scopes are set per key; read-only keys work for all `get_*`/`list_*` tools.
|
|
19
27
|
|
|
20
28
|
## CLI
|
|
21
29
|
|
|
22
30
|
```bash
|
|
23
31
|
npx rankcontrol funnel # AI crawls / AI visits / AI leads (30d)
|
|
32
|
+
npx rankcontrol score # composite visibility score + subscores (AI/Google/Bing)
|
|
33
|
+
npx rankcontrol leads --limit 100 # captured leads with AI source attribution
|
|
34
|
+
npx rankcontrol traffic --days 30 # page views, visitors, bounce rate, time on page
|
|
24
35
|
npx rankcontrol visibility --days 60 # daily AI visibility score trend
|
|
36
|
+
npx rankcontrol sov --days 30 # share of voice vs competitors
|
|
37
|
+
npx rankcontrol sources --days 30 # cited domains typed brand/competitive/ugc/editorial
|
|
38
|
+
npx rankcontrol sentiment --days 30 # how AI answers frame your brand
|
|
39
|
+
npx rankcontrol optimizer --limit 25 # pages ranked by citability, worst first
|
|
25
40
|
npx rankcontrol citations --model chatgpt
|
|
41
|
+
npx rankcontrol queries # tracked queries checked weekly across AI engines
|
|
26
42
|
npx rankcontrol capacity # remaining content-plan slots
|
|
27
43
|
npx rankcontrol plan-content --topics "bus travel" --max-difficulty 40
|
|
28
44
|
npx rankcontrol commit-titles --file titles.json
|
|
29
45
|
npx rankcontrol publish <contentId> # dry run
|
|
30
46
|
npx rankcontrol publish <contentId> --confirm # actually publish
|
|
47
|
+
npx rankcontrol generate <contentId> # dry run
|
|
48
|
+
npx rankcontrol generate <contentId> --confirm # write a planned article now (slot unchanged)
|
|
49
|
+
npx rankcontrol settings # article policy (auto-write, auto-publish, images, ...)
|
|
50
|
+
npx rankcontrol settings-set --auto-generate off --auto-publish off
|
|
51
|
+
npx rankcontrol reschedule <contentId> 2026-08-04
|
|
52
|
+
npx rankcontrol internal-links <contentId> # who links here / links out
|
|
53
|
+
npx rankcontrol site-pages # pages used for internal links
|
|
54
|
+
npx rankcontrol detect-links https://example.com/sitemap.xml
|
|
55
|
+
npx rankcontrol add-pages https://example.com/pricing
|
|
56
|
+
npx rankcontrol repurpose # published articles + social draft chips
|
|
57
|
+
npx rankcontrol repurpose <contentId> # full drafts for one article
|
|
58
|
+
npx rankcontrol repurpose-generate <contentId> --platforms twitter --confirm
|
|
59
|
+
npx rankcontrol repurpose-edit <draftId> --title "New hook"
|
|
60
|
+
npx rankcontrol repurpose-channels # Postiz channel ids (for repurpose-push)
|
|
61
|
+
npx rankcontrol repurpose-push <draftId> --channels <id> # dry run
|
|
62
|
+
npx rankcontrol repurpose-push <draftId> --channels <id> --confirm
|
|
63
|
+
npx rankcontrol repurpose-mark-posted <draftId> # posted it yourself
|
|
31
64
|
npx rankcontrol jobs # async job status (agent runs)
|
|
32
65
|
```
|
|
33
66
|
|
|
@@ -58,14 +91,34 @@ Or in `.mcp.json` / Cursor `mcp.json`:
|
|
|
58
91
|
| Tool | What it does |
|
|
59
92
|
|---|---|
|
|
60
93
|
| `get_overview_funnel` | AI crawls / AI visits / AI leads, last 30 days |
|
|
94
|
+
| `list_leads` | Captured leads with AI model / query / page attribution |
|
|
95
|
+
| `get_traffic_overview` | Page views, visitors, sessions, bounce rate, time on page |
|
|
61
96
|
| `get_visibility_trend` | Daily visibility score from stored weekly citation checks |
|
|
62
97
|
| `list_citations` | Recent citation checks per AI model |
|
|
63
|
-
| `
|
|
98
|
+
| `list_tracked_queries` | The queries checked weekly across the AI engines |
|
|
99
|
+
| `get_visibility_score` | Composite score (50% AI + 30% Google + 20% Bing) with subscores |
|
|
100
|
+
| `get_share_of_voice` | Your citation rate vs competitor mention rates + your share |
|
|
101
|
+
| `get_citation_sources` | Cited domains typed brand / competitive / UGC / editorial |
|
|
102
|
+
| `get_citation_sentiment` | Positive / neutral / negative framing with recent receipts |
|
|
103
|
+
| `get_citability_report` | Published pages by citability score, worst first, with fixes |
|
|
64
104
|
| `get_planning_capacity` | Remaining calendar slots (check before planning) |
|
|
65
105
|
| `plan_content` | Generate candidate titles (capacity-gated, nothing scheduled) |
|
|
66
106
|
| `commit_planned_titles` | Approve reviewed titles onto the calendar |
|
|
67
107
|
| `publish_content` | Dry run by default; `confirm: true` publishes |
|
|
108
|
+
| `generate_article` | Write a planned article's body now, slot unchanged; dry run by default |
|
|
109
|
+
| `get_article_settings` / `update_article_settings` | Read / merge-patch the article policy (auto-write, auto-publish, images, links, ...) |
|
|
110
|
+
| `reschedule_article` | Move a planned article to another day (flexible schedule) |
|
|
111
|
+
| `get_internal_links` | Who links to this article internally, and its outbound count |
|
|
112
|
+
| `list_site_pages` / `add_site_pages` | Pages used for in-article internal links |
|
|
113
|
+
| `detect_site_links` | Scan a sitemap or blog root for site pages |
|
|
114
|
+
| `list_repurpose_queue` | Published articles + social draft chips per platform |
|
|
115
|
+
| `get_repurpose_drafts` | Full social drafts for one article (8 platforms) |
|
|
116
|
+
| `generate_repurpose_drafts` | Draft social posts for an article; dry run by default |
|
|
117
|
+
| `update_repurpose_draft` | Edit a draft's body/title before pushing |
|
|
118
|
+
| `mark_repurpose_posted` | Record a draft as posted manually |
|
|
119
|
+
| `list_postiz_channels` | Connected Postiz channels (ids for pushing) |
|
|
120
|
+
| `push_repurpose_draft` | Send a draft to Postiz; dry run by default |
|
|
68
121
|
| `list_content` | Content pages with status |
|
|
69
122
|
| `list_jobs` | Async job status (agent runs) |
|
|
70
123
|
|
|
71
|
-
The plan→commit split is intentional: `plan_content` proposes, a human (or a supervising agent) reviews, `commit_planned_titles` approves. `publish_content`
|
|
124
|
+
The plan→commit split is intentional: `plan_content` proposes, a human (or a supervising agent) reviews, `commit_planned_titles` approves. `publish_content` and `generate_article` are dry-run-first for the same reason.
|
package/package.json
CHANGED
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
|
-
"RankControl from the terminal. Auth: export RANKCONTROL_API_KEY=rctrl_pk_...
|
|
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")
|
|
@@ -28,17 +31,75 @@ export function runCli(argv) {
|
|
|
28
31
|
await startMcpServer().catch(fail);
|
|
29
32
|
});
|
|
30
33
|
|
|
34
|
+
program
|
|
35
|
+
.command("login")
|
|
36
|
+
.description("Authenticate via your browser (approves a scoped API key into ~/.rankcontrol)")
|
|
37
|
+
.option("--scopes <a,b,c>", "Scopes to request (default: reads + write:content + write:publish)")
|
|
38
|
+
.action(async (opts) => {
|
|
39
|
+
const { login } = await import("./login.mjs");
|
|
40
|
+
await login({ scopes: opts.scopes ? list(opts.scopes) : undefined }).catch(fail);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command("logout")
|
|
45
|
+
.description("Remove locally stored credentials")
|
|
46
|
+
.action(async () => {
|
|
47
|
+
const { logout } = await import("./login.mjs");
|
|
48
|
+
logout();
|
|
49
|
+
});
|
|
50
|
+
|
|
31
51
|
program
|
|
32
52
|
.command("funnel")
|
|
33
53
|
.description("AI pipeline last 30 days: crawls, AI visits, AI leads")
|
|
34
54
|
.action(() => api.overviewFunnel().then(out).catch(fail));
|
|
35
55
|
|
|
56
|
+
program
|
|
57
|
+
.command("leads")
|
|
58
|
+
.description("Captured leads with source attribution (AI model, query, page)")
|
|
59
|
+
.option("--limit <n>", "Max rows, up to 500", "100")
|
|
60
|
+
.action((opts) => api.leads(Number(opts.limit)).then(out).catch(fail));
|
|
61
|
+
|
|
62
|
+
program
|
|
63
|
+
.command("traffic")
|
|
64
|
+
.description("Traffic overview: page views, visitors, sessions, bounce rate, time on page")
|
|
65
|
+
.option("--days <n>", "7, 30 or 90", "30")
|
|
66
|
+
.action((opts) => api.trafficOverview(Number(opts.days)).then(out).catch(fail));
|
|
67
|
+
|
|
68
|
+
program
|
|
69
|
+
.command("score")
|
|
70
|
+
.description("Composite visibility score: 50% AI citation rate + 30% Google + 20% Bing rank share, with subscores")
|
|
71
|
+
.action(() => api.visibilityScore().then(out).catch(fail));
|
|
72
|
+
|
|
36
73
|
program
|
|
37
74
|
.command("visibility")
|
|
38
75
|
.description("Daily AI visibility score trend from the stored weekly citation checks")
|
|
39
76
|
.option("--days <n>", "30, 60 or 90", "30")
|
|
40
77
|
.action((opts) => api.visibilityTrend(Number(opts.days)).then(out).catch(fail));
|
|
41
78
|
|
|
79
|
+
program
|
|
80
|
+
.command("sov")
|
|
81
|
+
.description("Share of voice: your citation rate vs top competitors, plus your share of all brand appearances")
|
|
82
|
+
.option("--days <n>", "Window in days, 7-90", "30")
|
|
83
|
+
.action((opts) => api.shareOfVoice(Number(opts.days)).then(out).catch(fail));
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command("sources")
|
|
87
|
+
.description("Domains AI answers cite for your tracked queries, typed brand/competitive/ugc/editorial")
|
|
88
|
+
.option("--days <n>", "Window in days, 7-90", "30")
|
|
89
|
+
.action((opts) => api.citationSources(Number(opts.days)).then(out).catch(fail));
|
|
90
|
+
|
|
91
|
+
program
|
|
92
|
+
.command("sentiment")
|
|
93
|
+
.description("How AI answers frame your brand when cited: positive/neutral/negative with recent receipts")
|
|
94
|
+
.option("--days <n>", "Window in days, 7-90", "30")
|
|
95
|
+
.action((opts) => api.citationSentiment(Number(opts.days)).then(out).catch(fail));
|
|
96
|
+
|
|
97
|
+
program
|
|
98
|
+
.command("optimizer")
|
|
99
|
+
.description("Published pages ranked by citability score, worst first, with their open fixes")
|
|
100
|
+
.option("--limit <n>", "Max rows", "25")
|
|
101
|
+
.action((opts) => api.optimizer(Number(opts.limit)).then(out).catch(fail));
|
|
102
|
+
|
|
42
103
|
program
|
|
43
104
|
.command("citations")
|
|
44
105
|
.description("Recent AI citation checks")
|
|
@@ -51,11 +112,115 @@ export function runCli(argv) {
|
|
|
51
112
|
.catch(fail)
|
|
52
113
|
);
|
|
53
114
|
|
|
115
|
+
program
|
|
116
|
+
.command("queries")
|
|
117
|
+
.description("List the tracked queries checked weekly across the AI engines")
|
|
118
|
+
.action(() => api.trackedQueries().then(out).catch(fail));
|
|
119
|
+
|
|
120
|
+
program
|
|
121
|
+
.command("crawler-access")
|
|
122
|
+
.description("Daily AI-crawler reachability probe: is the site's edge or robots.txt blocking GPTBot/ClaudeBot")
|
|
123
|
+
.action(() => api.crawlerAccess().then(out).catch(fail));
|
|
124
|
+
|
|
125
|
+
program
|
|
126
|
+
.command("query-add")
|
|
127
|
+
.description("Add a query to the tracking pool (tracks immediately when a plan slot is free)")
|
|
128
|
+
.argument("<queryText>", "The search/AI query to track")
|
|
129
|
+
.action((queryText) => api.addQuery(queryText).then(out).catch(fail));
|
|
130
|
+
|
|
54
131
|
program
|
|
55
132
|
.command("capacity")
|
|
56
133
|
.description("Remaining plan slots on the content calendar")
|
|
57
134
|
.action(() => api.planningCapacity().then(out).catch(fail));
|
|
58
135
|
|
|
136
|
+
program
|
|
137
|
+
.command("team")
|
|
138
|
+
.description("Workspace members and pending invites (owner surface)")
|
|
139
|
+
.action(() => api.team().then(out).catch(fail));
|
|
140
|
+
|
|
141
|
+
program
|
|
142
|
+
.command("team-invite")
|
|
143
|
+
.description("Invite a member (dry-run by default; --confirm sends the email)")
|
|
144
|
+
.argument("<email>", "Invitee email")
|
|
145
|
+
.option(
|
|
146
|
+
"--perms <area=level,...>",
|
|
147
|
+
"Per-screen levels, e.g. content=write,analytics=read (unspecified screens default to none)"
|
|
148
|
+
)
|
|
149
|
+
.option("--confirm", "Actually send the invite")
|
|
150
|
+
.action((email, opts) => {
|
|
151
|
+
const screenPermissions = {};
|
|
152
|
+
for (const pair of (opts.perms ?? "").split(",").filter(Boolean)) {
|
|
153
|
+
const [area, level] = pair.split("=");
|
|
154
|
+
screenPermissions[area?.trim()] = level?.trim();
|
|
155
|
+
}
|
|
156
|
+
return api
|
|
157
|
+
.teamInvite({
|
|
158
|
+
email,
|
|
159
|
+
...(Object.keys(screenPermissions).length ? { screenPermissions } : {}),
|
|
160
|
+
confirm: !!opts.confirm,
|
|
161
|
+
})
|
|
162
|
+
.then(out)
|
|
163
|
+
.catch(fail);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
program
|
|
167
|
+
.command("team-revoke")
|
|
168
|
+
.description("Revoke a pending invite")
|
|
169
|
+
.argument("<invitationId>", "Invitation id from `team`")
|
|
170
|
+
.action((invitationId) => api.teamRevoke(invitationId).then(out).catch(fail));
|
|
171
|
+
|
|
172
|
+
program
|
|
173
|
+
.command("team-remove")
|
|
174
|
+
.description("Remove a member (requires --confirm)")
|
|
175
|
+
.argument("<userId>", "Member user id from `team`")
|
|
176
|
+
.option("--confirm", "Actually remove the member")
|
|
177
|
+
.action((userId, opts) =>
|
|
178
|
+
api
|
|
179
|
+
.teamRemove({ userId, confirm: !!opts.confirm })
|
|
180
|
+
.then(out)
|
|
181
|
+
.catch(fail)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
program
|
|
185
|
+
.command("outreach-prospects")
|
|
186
|
+
.description("Link outreach pipeline with any found contact emails")
|
|
187
|
+
.action(() => api.outreachProspects().then(out).catch(fail));
|
|
188
|
+
|
|
189
|
+
program
|
|
190
|
+
.command("outreach-find-contact")
|
|
191
|
+
.description("Find an outreach email for a prospect (scrapes their site)")
|
|
192
|
+
.argument("<backlinkId>", "Prospect id from `outreach-prospects`")
|
|
193
|
+
.action((backlinkId) =>
|
|
194
|
+
api.outreachFindContact(backlinkId).then(out).catch(fail)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
program
|
|
198
|
+
.command("outreach-draft-reply")
|
|
199
|
+
.description("AI-draft a reply to a prospect's inbound response (nothing sends)")
|
|
200
|
+
.argument("<backlinkId>", "Prospect id from `outreach-prospects`")
|
|
201
|
+
.action((backlinkId) =>
|
|
202
|
+
api.outreachDraftReply(backlinkId).then(out).catch(fail)
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
program
|
|
206
|
+
.command("outreach-queue")
|
|
207
|
+
.description("Queue an outreach email to send from the connected mailbox (dry-run by default)")
|
|
208
|
+
.argument("<backlinkId>", "Prospect id from `outreach-prospects`")
|
|
209
|
+
.option("--subject <subject>", "Override the drafted subject")
|
|
210
|
+
.option("--body <body>", "Override the drafted body")
|
|
211
|
+
.option("--confirm", "Actually queue the send")
|
|
212
|
+
.action((backlinkId, opts) =>
|
|
213
|
+
api
|
|
214
|
+
.outreachQueue({
|
|
215
|
+
backlinkId,
|
|
216
|
+
...(opts.subject ? { subject: opts.subject } : {}),
|
|
217
|
+
...(opts.body ? { body: opts.body } : {}),
|
|
218
|
+
confirm: !!opts.confirm,
|
|
219
|
+
})
|
|
220
|
+
.then(out)
|
|
221
|
+
.catch(fail)
|
|
222
|
+
);
|
|
223
|
+
|
|
59
224
|
program
|
|
60
225
|
.command("jobs")
|
|
61
226
|
.description("Recent agent runs (async job status)")
|
|
@@ -114,5 +279,342 @@ export function runCli(argv) {
|
|
|
114
279
|
api.publishContent(contentId, !!opts.confirm).then(out).catch(fail)
|
|
115
280
|
);
|
|
116
281
|
|
|
282
|
+
program
|
|
283
|
+
.command("generate <contentId>")
|
|
284
|
+
.description("Write a planned article's body now, keeping its publish slot (past-due or ≤3 days ahead; dry run unless --confirm)")
|
|
285
|
+
.option("--confirm", "Actually start generation (spends LLM budget)")
|
|
286
|
+
.action((contentId, opts) =>
|
|
287
|
+
api.generateContent(contentId, !!opts.confirm).then(out).catch(fail)
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
program
|
|
291
|
+
.command("settings")
|
|
292
|
+
.description("Show the article policy: per-article defaults + flexible scheduling")
|
|
293
|
+
.action(() => api.articleSettings().then(out).catch(fail));
|
|
294
|
+
|
|
295
|
+
const onOff = (val) => {
|
|
296
|
+
if (val === "on" || val === "true") return true;
|
|
297
|
+
if (val === "off" || val === "false") return false;
|
|
298
|
+
throw new Error(`Expected on|off, got "${val}"`);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
program
|
|
302
|
+
.command("settings-set")
|
|
303
|
+
.description("Merge-patch the article policy (only flags you pass change)")
|
|
304
|
+
.option("--auto-publish <on|off>")
|
|
305
|
+
.option("--auto-generate <on|off>", "Off = fully manual: write articles only on demand (generate command / Write now)")
|
|
306
|
+
.option("--images <on|off>", "AI hero + section images")
|
|
307
|
+
.option("--title-in-hero <on|off>", "Write the post title into the hero image")
|
|
308
|
+
.option("--section-infographics <on|off>", "Informational panels in 2,000+ word articles")
|
|
309
|
+
.option("--related-reading <on|off>", "Related Reading link block at the end")
|
|
310
|
+
.option("--youtube <on|off>", "Embedded video suggestions")
|
|
311
|
+
.option("--emojis <on|off>")
|
|
312
|
+
.option("--internal-links <n>", "Internal links per article (1-20)")
|
|
313
|
+
.option("--external-links <n>", "Outbound authority links per article (0-15)")
|
|
314
|
+
.option("--instructions <text>", "Global editorial instructions")
|
|
315
|
+
.option("--flexible-schedule <on|off>", "1-5 articles/day + calendar drag-and-drop")
|
|
316
|
+
.option(
|
|
317
|
+
"--image-style <set>",
|
|
318
|
+
"Image style set for all generated images: classic-editorial | print-craft | storybook-painterly | modern-saas | dark-premium | bold-poster"
|
|
319
|
+
)
|
|
320
|
+
.action((opts) => {
|
|
321
|
+
try {
|
|
322
|
+
const s = {};
|
|
323
|
+
if (opts.autoPublish !== undefined) s.autoPublish = onOff(opts.autoPublish);
|
|
324
|
+
if (opts.autoGenerate !== undefined) s.autoGenerate = onOff(opts.autoGenerate);
|
|
325
|
+
if (opts.images !== undefined) s.includeInfographics = onOff(opts.images);
|
|
326
|
+
if (opts.titleInHero !== undefined) s.titleInHeroImage = onOff(opts.titleInHero);
|
|
327
|
+
if (opts.sectionInfographics !== undefined)
|
|
328
|
+
s.includeSectionInfographics = onOff(opts.sectionInfographics);
|
|
329
|
+
if (opts.relatedReading !== undefined)
|
|
330
|
+
s.includeRelatedReading = onOff(opts.relatedReading);
|
|
331
|
+
if (opts.youtube !== undefined) s.includeYouTube = onOff(opts.youtube);
|
|
332
|
+
if (opts.emojis !== undefined) s.useEmojis = onOff(opts.emojis);
|
|
333
|
+
if (opts.internalLinks !== undefined)
|
|
334
|
+
s.internalLinksPerArticle = Number(opts.internalLinks);
|
|
335
|
+
if (opts.externalLinks !== undefined)
|
|
336
|
+
s.externalLinksPerArticle = Number(opts.externalLinks);
|
|
337
|
+
if (opts.instructions !== undefined) s.globalInstructions = opts.instructions;
|
|
338
|
+
const body = {};
|
|
339
|
+
if (Object.keys(s).length > 0) body.articleSettings = s;
|
|
340
|
+
if (opts.flexibleSchedule !== undefined)
|
|
341
|
+
body.flexibleScheduling = onOff(opts.flexibleSchedule);
|
|
342
|
+
if (opts.imageStyle !== undefined) body.styleSet = opts.imageStyle;
|
|
343
|
+
if (Object.keys(body).length === 0)
|
|
344
|
+
throw new Error("Pass at least one flag to change (see --help)");
|
|
345
|
+
return api.updateArticleSettings(body).then(out).catch(fail);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
fail(err);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
program
|
|
352
|
+
.command("reschedule <contentId> <date>")
|
|
353
|
+
.description("Move a planned article to a day (YYYY-MM-DD, local time; needs the flexible schedule)")
|
|
354
|
+
.action((contentId, date) => {
|
|
355
|
+
const day = new Date(`${date}T00:00:00`);
|
|
356
|
+
if (Number.isNaN(day.getTime())) return fail(new Error("Date must be YYYY-MM-DD"));
|
|
357
|
+
return api.reschedule(contentId, day.getTime()).then(out).catch(fail);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
program
|
|
361
|
+
.command("internal-links <contentId>")
|
|
362
|
+
.description("Inbound and outbound internal links recorded for an article (from the publish-time link graph)")
|
|
363
|
+
.action((contentId) => api.internalLinks(contentId).then(out).catch(fail));
|
|
364
|
+
|
|
365
|
+
program
|
|
366
|
+
.command("site-pages")
|
|
367
|
+
.description("List the site pages used for in-article links and Related Reading")
|
|
368
|
+
.action(() => api.sitePages().then(out).catch(fail));
|
|
369
|
+
|
|
370
|
+
program
|
|
371
|
+
.command("detect-links <url>")
|
|
372
|
+
.description("Scan a sitemap (nested indexes too) or a blog root page for site pages")
|
|
373
|
+
.option("--blog-root", "Treat the URL as a page to crawl instead of a sitemap")
|
|
374
|
+
.action((url, opts) =>
|
|
375
|
+
api
|
|
376
|
+
.detectSiteLinks(opts.blogRoot ? "blogroot" : "sitemap", url)
|
|
377
|
+
.then(out)
|
|
378
|
+
.catch(fail)
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
program
|
|
382
|
+
.command("add-pages <urls...>")
|
|
383
|
+
.description("Add site page URLs for internal linking (dedupes; titles auto-fill)")
|
|
384
|
+
.action((urls) => api.addSitePages(urls).then(out).catch(fail));
|
|
385
|
+
|
|
386
|
+
program
|
|
387
|
+
.command("repurpose [contentId]")
|
|
388
|
+
.description("Repurpose queue (published articles + draft chips), or full drafts for one article")
|
|
389
|
+
.action((contentId) =>
|
|
390
|
+
(contentId ? api.repurposeDrafts(contentId) : api.repurposeQueue())
|
|
391
|
+
.then(out)
|
|
392
|
+
.catch(fail)
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
program
|
|
396
|
+
.command("repurpose-generate <contentId>")
|
|
397
|
+
.description("Draft social posts for a published article (dry run unless --confirm; spends LLM budget)")
|
|
398
|
+
.option(
|
|
399
|
+
"--platforms <a,b>",
|
|
400
|
+
"Subset: linkedin,twitter,pinterest,instagram,facebook,threads,youtube,tiktok"
|
|
401
|
+
)
|
|
402
|
+
.option("--confirm", "Actually generate")
|
|
403
|
+
.action((contentId, opts) =>
|
|
404
|
+
api
|
|
405
|
+
.repurposeGenerate({
|
|
406
|
+
contentId,
|
|
407
|
+
platforms: opts.platforms ? list(opts.platforms) : undefined,
|
|
408
|
+
confirm: !!opts.confirm,
|
|
409
|
+
})
|
|
410
|
+
.then(out)
|
|
411
|
+
.catch(fail)
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
program
|
|
415
|
+
.command("repurpose-edit <draftId>")
|
|
416
|
+
.description("Edit a repurpose draft's text/title before pushing")
|
|
417
|
+
.option("--body <text>")
|
|
418
|
+
.option("--title <text>")
|
|
419
|
+
.action((draftId, opts) =>
|
|
420
|
+
api
|
|
421
|
+
.repurposeEditDraft({ draftId, body: opts.body, title: opts.title })
|
|
422
|
+
.then(out)
|
|
423
|
+
.catch(fail)
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
program
|
|
427
|
+
.command("repurpose-mark-posted <draftId>")
|
|
428
|
+
.description("Mark a draft as posted (you published it yourself)")
|
|
429
|
+
.action((draftId) => api.repurposeMarkPosted(draftId).then(out).catch(fail));
|
|
430
|
+
|
|
431
|
+
program
|
|
432
|
+
.command("repurpose-channels")
|
|
433
|
+
.description("Connected Postiz channels (ids needed for repurpose-push)")
|
|
434
|
+
.action(() => api.repurposeChannels().then(out).catch(fail));
|
|
435
|
+
|
|
436
|
+
program
|
|
437
|
+
.command("repurpose-push <draftId>")
|
|
438
|
+
.description("Send a draft to Postiz (dry run unless --confirm)")
|
|
439
|
+
.option("--channels <id1,id2>", "Postiz channel ids (see repurpose-channels)")
|
|
440
|
+
.option("--when <now|schedule|draft>", "Postiz mode", "now")
|
|
441
|
+
.option("--date <iso>", "ISO time for --when schedule")
|
|
442
|
+
.option("--confirm", "Actually push (publicly visible)")
|
|
443
|
+
.action((draftId, opts) =>
|
|
444
|
+
api
|
|
445
|
+
.repurposePush({
|
|
446
|
+
draftId,
|
|
447
|
+
integrationIds: opts.channels ? list(opts.channels) : undefined,
|
|
448
|
+
scheduleType: opts.when,
|
|
449
|
+
date: opts.date,
|
|
450
|
+
confirm: !!opts.confirm,
|
|
451
|
+
})
|
|
452
|
+
.then(out)
|
|
453
|
+
.catch(fail)
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const parseJson = (raw, label) => {
|
|
457
|
+
try {
|
|
458
|
+
return JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
|
|
459
|
+
} catch {
|
|
460
|
+
fail(new Error(`--json for ${label} must be valid JSON (or @file.json)`));
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
program
|
|
465
|
+
.command("ideas")
|
|
466
|
+
.description("Scored content-idea backlog: uncovered queries, citation gaps, quick wins")
|
|
467
|
+
.action(() => api.contentIdeas().then(out).catch(fail));
|
|
468
|
+
|
|
469
|
+
program
|
|
470
|
+
.command("plan-idea <queryText>")
|
|
471
|
+
.description("Put an idea on the content calendar (uses its stored title, or generates one)")
|
|
472
|
+
.option("--title <title>", "Exact headline to plan")
|
|
473
|
+
.action((queryText, opts) =>
|
|
474
|
+
api.planIdea({ queryText, title: opts.title }).then(out).catch(fail)
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
program
|
|
478
|
+
.command("archive <contentId>")
|
|
479
|
+
.description("Archive an article (removes it from the working set)")
|
|
480
|
+
.action((contentId) => api.archiveContent(contentId).then(out).catch(fail));
|
|
481
|
+
|
|
482
|
+
program
|
|
483
|
+
.command("engagement")
|
|
484
|
+
.description("Per-page views + citations for the last 30 days, with view sparklines")
|
|
485
|
+
.action(() => api.pageEngagement().then(out).catch(fail));
|
|
486
|
+
|
|
487
|
+
program
|
|
488
|
+
.command("report")
|
|
489
|
+
.description("Executive summary: last 30 days vs the 30 before (citations, views, crawls, publishing)")
|
|
490
|
+
.action(() => api.reportSummary().then(out).catch(fail));
|
|
491
|
+
|
|
492
|
+
program
|
|
493
|
+
.command("wins")
|
|
494
|
+
.description("Biggest wins of the last 30 days: top cited page, cite-rate jump, ranking climb, best backlink")
|
|
495
|
+
.action(() => api.reportWins().then(out).catch(fail));
|
|
496
|
+
|
|
497
|
+
program
|
|
498
|
+
.command("agent-activity")
|
|
499
|
+
.description("Recent runs per agent lane (brand control, radar, forge, deploy, ...)")
|
|
500
|
+
.option("--per-agent <n>", "Runs per lane, up to 50", "15")
|
|
501
|
+
.option("--since-days <n>", "Window in days", "30")
|
|
502
|
+
.action((opts) =>
|
|
503
|
+
api
|
|
504
|
+
.agentActivity({ perAgent: Number(opts.perAgent), sinceDays: Number(opts.sinceDays) })
|
|
505
|
+
.then(out)
|
|
506
|
+
.catch(fail)
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
program
|
|
510
|
+
.command("backlinks")
|
|
511
|
+
.description("The backlink table, newest first")
|
|
512
|
+
.option("--status <status>", "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected")
|
|
513
|
+
.action((opts) => api.backlinks(opts.status).then(out).catch(fail));
|
|
514
|
+
|
|
515
|
+
program
|
|
516
|
+
.command("backlink-stats")
|
|
517
|
+
.description("Backlink totals + the outreach pipeline counts")
|
|
518
|
+
.action(() => api.backlinkStats().then(out).catch(fail));
|
|
519
|
+
|
|
520
|
+
program
|
|
521
|
+
.command("outreach-status <backlinkId> <status>")
|
|
522
|
+
.description("Move an outreach prospect through the pipeline (identified|contacted|replied|link_placed|rejected)")
|
|
523
|
+
.action((backlinkId, status) =>
|
|
524
|
+
api.outreachStatus(backlinkId, status).then(out).catch(fail)
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
program
|
|
528
|
+
.command("network")
|
|
529
|
+
.description("Link Network credit stats, membership state, and hosted/received placements")
|
|
530
|
+
.action(() => api.linkNetwork().then(out).catch(fail));
|
|
531
|
+
|
|
532
|
+
program
|
|
533
|
+
.command("network-opt-in <on|off>")
|
|
534
|
+
.description("Join or leave the Link Network (dry run unless --confirm; leaving retires live links)")
|
|
535
|
+
.option("--confirm", "Actually change membership")
|
|
536
|
+
.action((state, opts) =>
|
|
537
|
+
api.linkNetworkOptIn(state === "on", !!opts.confirm).then(out).catch(fail)
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
program
|
|
541
|
+
.command("network-remove-placement <placementId>")
|
|
542
|
+
.description("Retire one network placement (dry run unless --confirm; visible on the partner site)")
|
|
543
|
+
.option("--confirm", "Actually remove it")
|
|
544
|
+
.action((placementId, opts) =>
|
|
545
|
+
api.linkNetworkRemovePlacement(placementId, !!opts.confirm).then(out).catch(fail)
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
program
|
|
549
|
+
.command("social")
|
|
550
|
+
.description("Social thread prospects (Reddit/X) with rules context and intent sort")
|
|
551
|
+
.option("--platform <reddit|twitter>")
|
|
552
|
+
.option("--status <status>")
|
|
553
|
+
.option("--age <ranked|new>")
|
|
554
|
+
.action((opts) =>
|
|
555
|
+
api
|
|
556
|
+
.socialThreads({ platform: opts.platform, status: opts.status, age: opts.age })
|
|
557
|
+
.then(out)
|
|
558
|
+
.catch(fail)
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
program
|
|
562
|
+
.command("social-stats")
|
|
563
|
+
.description("Social thread pipeline counts")
|
|
564
|
+
.action(() => api.socialStats().then(out).catch(fail));
|
|
565
|
+
|
|
566
|
+
program
|
|
567
|
+
.command("social-status <threadId> <status>")
|
|
568
|
+
.description("Move a social thread through the pipeline")
|
|
569
|
+
.action((threadId, status) =>
|
|
570
|
+
api.socialStatus(threadId, status).then(out).catch(fail)
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
program
|
|
574
|
+
.command("social-draft-reply <threadId>")
|
|
575
|
+
.description("AI-draft a reply for a thread (nothing posts; the draft lands for review)")
|
|
576
|
+
.option("--mention <none|natural|founderOpen>", "Brand-mention mode")
|
|
577
|
+
.action((threadId, opts) =>
|
|
578
|
+
api.socialDraftReply(threadId, opts.mention).then(out).catch(fail)
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
program
|
|
582
|
+
.command("support <subject>")
|
|
583
|
+
.description("Send a message or bug report to RankControl support (emails the team, capped 3/min)")
|
|
584
|
+
.requiredOption("--message <text>", "What happened — include errors, ids, and steps")
|
|
585
|
+
.option("--page <url>", "Related dashboard or API URL")
|
|
586
|
+
.action((subject, opts) =>
|
|
587
|
+
api.support({ subject, message: opts.message, pageUrl: opts.page }).then(out).catch(fail)
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
program
|
|
591
|
+
.command("brand")
|
|
592
|
+
.description("Brand profile, products, and buyer profiles (ICPs) in one read")
|
|
593
|
+
.action(() => api.brand().then(out).catch(fail));
|
|
594
|
+
|
|
595
|
+
program
|
|
596
|
+
.command("brand-profile-set")
|
|
597
|
+
.description("Patch brand voice/style fields. --json '{\"tone\":\"...\"}' or --json @file.json")
|
|
598
|
+
.requiredOption("--json <json>", "Fields: tone, primaryColor, secondaryColor, fontFamily, metaTitle, metaDescription, metaKeywords, defaultLocale, flexibleScheduling")
|
|
599
|
+
.action((opts) =>
|
|
600
|
+
api.brandProfileSet(parseJson(opts.json, "brand-profile-set")).then(out).catch(fail)
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
program
|
|
604
|
+
.command("brand-product")
|
|
605
|
+
.description("Create, update, or delete a product. Create: name+description+category; update: productId + fields; delete: productId + del:true")
|
|
606
|
+
.requiredOption("--json <json>", "Product JSON (or @file.json)")
|
|
607
|
+
.action((opts) =>
|
|
608
|
+
api.brandProductWrite(parseJson(opts.json, "brand-product")).then(out).catch(fail)
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
program
|
|
612
|
+
.command("brand-icp")
|
|
613
|
+
.description("Create, update, or delete a buyer profile. Create: title+industry+demographics; update: profileId + fields; delete: profileId + del:true")
|
|
614
|
+
.requiredOption("--json <json>", "Buyer profile JSON (or @file.json)")
|
|
615
|
+
.action((opts) =>
|
|
616
|
+
api.brandIcpWrite(parseJson(opts.json, "brand-icp")).then(out).catch(fail)
|
|
617
|
+
);
|
|
618
|
+
|
|
117
619
|
program.parseAsync(argv);
|
|
118
620
|
}
|