rankrush-cli 0.1.37__py3-none-any.whl

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.
Files changed (146) hide show
  1. rankrush/__init__.py +3 -0
  2. rankrush/cache.py +83 -0
  3. rankrush/cli.py +241 -0
  4. rankrush/client.py +227 -0
  5. rankrush/commands/__init__.py +0 -0
  6. rankrush/commands/audit.py +307 -0
  7. rankrush/commands/blog.py +175 -0
  8. rankrush/commands/buzz/__init__.py +0 -0
  9. rankrush/commands/buzz/drafts.py +84 -0
  10. rankrush/commands/buzz/posts.py +97 -0
  11. rankrush/commands/buzz/research.py +108 -0
  12. rankrush/commands/buzz/strategy.py +47 -0
  13. rankrush/commands/cms.py +80 -0
  14. rankrush/commands/init.py +223 -0
  15. rankrush/commands/keywords.py +91 -0
  16. rankrush/commands/leadgen.py +125 -0
  17. rankrush/commands/leads.py +253 -0
  18. rankrush/commands/nodes.py +227 -0
  19. rankrush/commands/profiles.py +64 -0
  20. rankrush/commands/report.py +279 -0
  21. rankrush/commands/scans.py +88 -0
  22. rankrush/commands/smoke.py +147 -0
  23. rankrush/commands/visibility.py +354 -0
  24. rankrush/config.py +44 -0
  25. rankrush/edge.py +299 -0
  26. rankrush/env_file.py +140 -0
  27. rankrush/errors.py +119 -0
  28. rankrush/first_run.py +117 -0
  29. rankrush/leadgen/__init__.py +39 -0
  30. rankrush/leadgen/dedupe.py +135 -0
  31. rankrush/leadgen/footprints.py +156 -0
  32. rankrush/leadgen/pipeline.py +191 -0
  33. rankrush/leadgen/sampler.py +161 -0
  34. rankrush/leadgen/store.py +101 -0
  35. rankrush/leadgen/wat_parser.py +180 -0
  36. rankrush/mcp_server.py +363 -0
  37. rankrush/options.py +40 -0
  38. rankrush/output.py +56 -0
  39. rankrush_cli-0.1.37.dist-info/METADATA +301 -0
  40. rankrush_cli-0.1.37.dist-info/RECORD +146 -0
  41. rankrush_cli-0.1.37.dist-info/WHEEL +5 -0
  42. rankrush_cli-0.1.37.dist-info/entry_points.txt +3 -0
  43. rankrush_cli-0.1.37.dist-info/licenses/LICENSE +21 -0
  44. rankrush_cli-0.1.37.dist-info/top_level.txt +2 -0
  45. rankrush_client/__init__.py +232 -0
  46. rankrush_client/api/__init__.py +11 -0
  47. rankrush_client/api/admin_api.py +1181 -0
  48. rankrush_client/api/audit_api.py +1178 -0
  49. rankrush_client/api/blog_api.py +1467 -0
  50. rankrush_client/api/buzz_api.py +2322 -0
  51. rankrush_client/api/jwt_only_api.py +2021 -0
  52. rankrush_client/api/profiles_api.py +600 -0
  53. rankrush_client/api/visibility_api.py +898 -0
  54. rankrush_client/api_client.py +804 -0
  55. rankrush_client/api_response.py +21 -0
  56. rankrush_client/configuration.py +640 -0
  57. rankrush_client/exceptions.py +218 -0
  58. rankrush_client/models/__init__.py +102 -0
  59. rankrush_client/models/audit_enqueue_request.py +104 -0
  60. rankrush_client/models/audit_enqueue_response.py +112 -0
  61. rankrush_client/models/audit_run.py +142 -0
  62. rankrush_client/models/audit_run_get_request.py +91 -0
  63. rankrush_client/models/audit_run_get_response.py +104 -0
  64. rankrush_client/models/blog_topic.py +108 -0
  65. rankrush_client/models/blog_topics_list_request.py +92 -0
  66. rankrush_client/models/blog_topics_list_response.py +101 -0
  67. rankrush_client/models/buzz_brand_research_response.py +100 -0
  68. rankrush_client/models/buzz_brand_research_response_research_summary.py +106 -0
  69. rankrush_client/models/buzz_brand_research_response_research_summary_sentiment_breakdown.py +92 -0
  70. rankrush_client/models/buzz_brand_research_response_research_summary_top_subreddits_inner.py +90 -0
  71. rankrush_client/models/buzz_draft_approve_request.py +92 -0
  72. rankrush_client/models/buzz_engagement.py +135 -0
  73. rankrush_client/models/buzz_engagements_list_request.py +98 -0
  74. rankrush_client/models/buzz_engagements_list_response.py +100 -0
  75. rankrush_client/models/buzz_extract_profile_response.py +94 -0
  76. rankrush_client/models/buzz_extract_profile_response_extracted.py +98 -0
  77. rankrush_client/models/buzz_generate_drafts_request.py +102 -0
  78. rankrush_client/models/buzz_generate_drafts_response.py +114 -0
  79. rankrush_client/models/buzz_mention.py +134 -0
  80. rankrush_client/models/buzz_opportunity_search_response.py +96 -0
  81. rankrush_client/models/buzz_profile_get_response.py +104 -0
  82. rankrush_client/models/buzz_research_list_request.py +94 -0
  83. rankrush_client/models/buzz_research_list_response.py +101 -0
  84. rankrush_client/models/buzz_visibility_score_response.py +125 -0
  85. rankrush_client/models/buzz_visibility_score_response_engagement_metrics.py +96 -0
  86. rankrush_client/models/buzz_visibility_score_response_top_subreddits_inner.py +94 -0
  87. rankrush_client/models/buzz_wait_request.py +90 -0
  88. rankrush_client/models/cap_error.py +108 -0
  89. rankrush_client/models/check_ai_visibility_request.py +112 -0
  90. rankrush_client/models/check_ai_visibility_response.py +114 -0
  91. rankrush_client/models/check_ai_visibility_response_dropped_inner.py +92 -0
  92. rankrush_client/models/create_lead_account_request.py +105 -0
  93. rankrush_client/models/create_lead_account_response.py +109 -0
  94. rankrush_client/models/create_lead_account_response_scan.py +99 -0
  95. rankrush_client/models/delete_audit_run_request.py +88 -0
  96. rankrush_client/models/delete_audit_run_response.py +101 -0
  97. rankrush_client/models/delete_audit_run_response_deleted.py +92 -0
  98. rankrush_client/models/error.py +102 -0
  99. rankrush_client/models/extract_offerings_request.py +90 -0
  100. rankrush_client/models/extract_offerings_response.py +108 -0
  101. rankrush_client/models/generate_blog_post_request.py +93 -0
  102. rankrush_client/models/generate_blog_post_response.py +98 -0
  103. rankrush_client/models/generate_blog_topics_request.py +93 -0
  104. rankrush_client/models/generate_content_strategy_response.py +97 -0
  105. rankrush_client/models/generate_node_content_request.py +133 -0
  106. rankrush_client/models/generate_node_content_response.py +101 -0
  107. rankrush_client/models/generate_node_content_response_content.py +143 -0
  108. rankrush_client/models/generate_visibility_keywords_response.py +115 -0
  109. rankrush_client/models/generate_visibility_keywords_response_keywords_inner.py +100 -0
  110. rankrush_client/models/generate_visibility_keywords_response_sources.py +90 -0
  111. rankrush_client/models/gsc_fetch_queries_response.py +105 -0
  112. rankrush_client/models/gsc_fetch_queries_response_queries_inner.py +96 -0
  113. rankrush_client/models/gsc_select_property_request.py +90 -0
  114. rankrush_client/models/job_queued_response.py +109 -0
  115. rankrush_client/models/keywords_list_request.py +92 -0
  116. rankrush_client/models/keywords_list_response.py +93 -0
  117. rankrush_client/models/lead_enqueue_request.py +98 -0
  118. rankrush_client/models/lead_enqueue_response.py +114 -0
  119. rankrush_client/models/lead_enqueue_result.py +103 -0
  120. rankrush_client/models/lead_item.py +110 -0
  121. rankrush_client/models/node_complete_request.py +92 -0
  122. rankrush_client/models/node_complete_response.py +108 -0
  123. rankrush_client/models/node_progress.py +115 -0
  124. rankrush_client/models/node_progress_summary.py +97 -0
  125. rankrush_client/models/nodes_list_request.py +104 -0
  126. rankrush_client/models/nodes_list_response.py +101 -0
  127. rankrush_client/models/ok_response.py +95 -0
  128. rankrush_client/models/profile.py +122 -0
  129. rankrush_client/models/profile_id_camel_request.py +88 -0
  130. rankrush_client/models/profile_id_snake_request.py +88 -0
  131. rankrush_client/models/profile_update_request.py +104 -0
  132. rankrush_client/models/profiles_list_request.py +89 -0
  133. rankrush_client/models/rate_limit_error.py +112 -0
  134. rankrush_client/models/report_job_get_request.py +89 -0
  135. rankrush_client/models/report_job_get_response.py +142 -0
  136. rankrush_client/models/save_cms_connection_request.py +96 -0
  137. rankrush_client/models/save_cms_connection_response.py +99 -0
  138. rankrush_client/models/suggest_competitors_request.py +118 -0
  139. rankrush_client/models/suggest_competitors_response.py +107 -0
  140. rankrush_client/models/suggest_competitors_response_category.py +96 -0
  141. rankrush_client/models/suggest_competitors_response_competitors_inner.py +102 -0
  142. rankrush_client/models/validation_error_body.py +102 -0
  143. rankrush_client/models/verify_blog_posts_request.py +90 -0
  144. rankrush_client/models/verify_blog_posts_response.py +98 -0
  145. rankrush_client/py.typed +0 -0
  146. rankrush_client/rest.py +263 -0
@@ -0,0 +1,307 @@
1
+ """audit — website audit commands.
2
+
3
+ All commands run on the v2 queue (audit-enqueue → process-audit-queue), the
4
+ same backend the React app uses, so CLI-triggered audits write to ``audit_runs``
5
+ / ``node_progress`` and are immediately visible in app.rankrush.ai's history.
6
+
7
+ run Enqueue a bulk audit, wait for it, and print a pass/fail summary.
8
+ (Convenience wrapper over ``start --wait`` + a node summary.)
9
+ start Enqueue a bulk or single-node audit; optionally --wait for it.
10
+ status Poll an in-flight audit by run id.
11
+ get Read the full audit_runs row + aggregated node results.
12
+ delete Remove an audit run (admin-only).
13
+
14
+ The audited site + industry come from the profile's configuration, not from
15
+ flags. ``run`` replaces the retired verify-nodes (audit v1) sync path.
16
+ """
17
+
18
+ import time
19
+
20
+ import click
21
+
22
+ from rankrush import client, edge, output
23
+ from rankrush.errors import CliError, MalformedResponseError, NotFoundError
24
+ from rankrush.first_run import resolve_profile_id
25
+ from rankrush.options import profile_option
26
+
27
+ # Terminal audit_runs.status values — when reached, no further polling needed.
28
+ # Source of truth: supabase/functions/process-audit-queue/index.ts writes
29
+ # `'completed'` and `'failed'`; `'cancelled'` comes from the watchdog migration.
30
+ _TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
31
+
32
+
33
+ @click.group("audit")
34
+ def audit_group():
35
+ """Run and inspect website audits."""
36
+
37
+
38
+ @audit_group.command("run")
39
+ @profile_option
40
+ @click.option(
41
+ "--poll-interval",
42
+ type=int,
43
+ default=10,
44
+ show_default=True,
45
+ help="Seconds between status polls while waiting.",
46
+ )
47
+ @click.option(
48
+ "--timeout",
49
+ type=int,
50
+ default=600,
51
+ show_default=True,
52
+ help="Hard ceiling on the wait, in seconds.",
53
+ )
54
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
55
+ def run(profile_id: str | None, business: str | None, poll_interval: int, timeout: int, as_json: bool):
56
+ """Run a full bulk audit and wait for it to finish.
57
+
58
+ Convenience wrapper over the v2 queue: enqueues a bulk audit
59
+ (audit-enqueue), polls until it reaches a terminal status, then prints a
60
+ node pass/fail summary. Equivalent to `audit start --wait` plus the summary.
61
+
62
+ The audited site + industry come from the profile's configuration (set them
63
+ in the app), not from flags — this replaces the retired verify-nodes (v1)
64
+ path, which took an explicit --url. Takes 30–120 seconds.
65
+ """
66
+ profile_id = resolve_profile_id(profile_id, business)
67
+
68
+ if not as_json:
69
+ click.echo("Running bulk audit for this profile...")
70
+ click.echo("This may take 30-120 seconds.")
71
+
72
+ enqueued = client.invoke_function(
73
+ "audit-enqueue", {"profile_id": profile_id, "mode": "bulk"}, timeout=30
74
+ )
75
+ run_id = enqueued.get("auditRunId")
76
+ if not run_id:
77
+ raise MalformedResponseError(
78
+ f"audit-enqueue did not return an auditRunId — got {enqueued!r}"
79
+ )
80
+ if not as_json:
81
+ click.echo(f"Audit enqueued: {run_id}")
82
+
83
+ _poll_until_terminal(run_id, poll_interval, timeout, as_json)
84
+
85
+ # Pull the completed run + per-node summary for the pass/fail printout.
86
+ # audit_runs.audit_results is the v1-compatible check array ([{passed, ...}]),
87
+ # so the summary matches what the old verify-nodes path printed.
88
+ result = edge.audit_run_get(run_id, include_nodes=True)
89
+ run_row = result["run"]
90
+ nodes = result["nodes"]
91
+
92
+ if not as_json:
93
+ run_status = run_row.get("status") or "completed"
94
+ counts = _passed_counts(run_row)
95
+ if counts is not None:
96
+ passed, total = counts
97
+ click.echo(f"\nAudit {run_status}: {passed}/{total} nodes passed.")
98
+ else:
99
+ click.echo(f"\nAudit {run_status}. Nodes touched: {len(nodes)}")
100
+ for status_name, count in result["node_counts"].items():
101
+ click.echo(f" {status_name}: {count}")
102
+ if run_row.get("error_message"):
103
+ click.echo(f"Error: {run_row['error_message']}")
104
+
105
+ output.emit(result, as_json)
106
+
107
+
108
+ # ─── v2 path: audit-enqueue → process-audit-queue ─────────────────────────────
109
+
110
+
111
+ def _poll_until_terminal(
112
+ run_id: str, poll_interval: int, timeout: int, as_json: bool
113
+ ) -> dict:
114
+ """Poll audit_runs by id until a terminal status; return the terminal row.
115
+
116
+ Shared by `audit run` and `audit start --wait`. Echoes each status
117
+ transition in human mode. Raises CliError if the deadline passes first.
118
+ """
119
+ deadline = time.monotonic() + timeout
120
+ last_status: str | None = None
121
+ while True:
122
+ row = edge.audit_run_get(run_id)["run"]
123
+ status = row.get("status") or "unknown"
124
+
125
+ if not as_json and status != last_status:
126
+ click.echo(f" → status: {status}")
127
+ last_status = status
128
+
129
+ if status in _TERMINAL_STATUSES:
130
+ return row
131
+
132
+ if time.monotonic() >= deadline:
133
+ raise CliError(
134
+ f"Audit {run_id} did not reach a terminal status within {timeout}s "
135
+ f"(last seen: {status}). Use `rankrush audit status {run_id}` to keep checking."
136
+ )
137
+
138
+ time.sleep(poll_interval)
139
+
140
+
141
+ @audit_group.command("start")
142
+ @profile_option
143
+ @click.option(
144
+ "--node-id",
145
+ "node_id",
146
+ default=None,
147
+ help="If set, runs a single-node audit (mode=single) instead of bulk.",
148
+ )
149
+ @click.option(
150
+ "--wait",
151
+ is_flag=True,
152
+ default=False,
153
+ help="Block until the audit reaches a terminal status. Otherwise return the run id immediately.",
154
+ )
155
+ @click.option(
156
+ "--poll-interval",
157
+ type=int,
158
+ default=10,
159
+ show_default=True,
160
+ help="Seconds between status polls when --wait is set.",
161
+ )
162
+ @click.option(
163
+ "--timeout",
164
+ type=int,
165
+ default=600,
166
+ show_default=True,
167
+ help="Hard ceiling on --wait, in seconds. Aborts polling at this duration.",
168
+ )
169
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
170
+ def start(
171
+ profile_id: str | None,
172
+ business: str | None,
173
+ node_id: str | None,
174
+ wait: bool,
175
+ poll_interval: int,
176
+ timeout: int,
177
+ as_json: bool,
178
+ ):
179
+ """Enqueue a v2 audit. Returns the run id; optionally waits for completion.
180
+
181
+ Unlike `audit run` (legacy v1), this path writes to ``audit_runs`` and
182
+ ``node_progress`` — the same tables the React frontend reads from. The
183
+ run becomes visible in app.rankrush.ai/audit history immediately, and
184
+ per-node results land in the tech tree as the worker processes them.
185
+ """
186
+ profile_id = resolve_profile_id(profile_id, business)
187
+
188
+ body: dict = {"profile_id": profile_id, "mode": "single" if node_id else "bulk"}
189
+ if node_id:
190
+ body["node_filter"] = [node_id]
191
+
192
+ result = client.invoke_function("audit-enqueue", body, timeout=30)
193
+ run_id = result.get("auditRunId")
194
+ if not run_id:
195
+ raise MalformedResponseError(
196
+ f"audit-enqueue did not return an auditRunId — got {result!r}"
197
+ )
198
+
199
+ if not as_json:
200
+ click.echo(f"Audit enqueued: {run_id}")
201
+ click.echo(f"Mode: {result.get('mode')} Enqueued at: {result.get('enqueuedAt')}")
202
+
203
+ if not wait:
204
+ output.emit(result, as_json)
205
+ return
206
+
207
+ row = _poll_until_terminal(run_id, poll_interval, timeout, as_json)
208
+ if not as_json:
209
+ click.echo(
210
+ f"\nAudit {row.get('status')}. "
211
+ f"Started {row.get('started_at')} · "
212
+ f"Completed {row.get('completed_at')}"
213
+ )
214
+ if row.get("status") == "failed" and row.get("error_message"):
215
+ click.echo(f"Error: {row['error_message']}")
216
+ output.emit(row, as_json)
217
+
218
+
219
+ @audit_group.command("status")
220
+ @click.argument("run_id")
221
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
222
+ def status(run_id: str, as_json: bool):
223
+ """Show the current status of a v2 audit run by id."""
224
+ try:
225
+ run = edge.audit_run_get(run_id)["run"]
226
+ except NotFoundError:
227
+ raise CliError(f"No audit run found for id {run_id!r}.")
228
+ # `status` is a deliberately light view - project to the lifecycle fields
229
+ # (no audit_results / scores), matching the old direct-table select.
230
+ row = {
231
+ k: run.get(k)
232
+ for k in (
233
+ "id", "status", "mode", "enqueued_at", "started_at", "completed_at",
234
+ "error_message", "website_url", "industry",
235
+ )
236
+ }
237
+ if not as_json:
238
+ click.echo(f"Run: {row['id']}")
239
+ click.echo(f"Status: {row.get('status')}")
240
+ click.echo(f"Mode: {row.get('mode')}")
241
+ click.echo(f"Website: {row.get('website_url')}")
242
+ click.echo(f"Industry: {row.get('industry')}")
243
+ click.echo(f"Enqueued: {row.get('enqueued_at')}")
244
+ click.echo(f"Started: {row.get('started_at') or '—'}")
245
+ click.echo(f"Completed: {row.get('completed_at') or '—'}")
246
+ if row.get("error_message"):
247
+ click.echo(f"Error: {row['error_message']}")
248
+ output.emit(row, as_json)
249
+
250
+
251
+ @audit_group.command("get")
252
+ @click.argument("run_id")
253
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
254
+ def get(run_id: str, as_json: bool):
255
+ """Fetch the audit run including audit_results + a per-node summary."""
256
+ try:
257
+ summary = edge.audit_run_get(run_id, include_nodes=True)
258
+ except NotFoundError:
259
+ raise CliError(f"No audit run found for id {run_id!r}.")
260
+ run = summary["run"]
261
+ nodes = summary["nodes"]
262
+
263
+ if not as_json:
264
+ click.echo(f"Run {run['id']} status={run.get('status')}")
265
+ click.echo(f"Mode={run.get('mode')} Industry={run.get('industry')}")
266
+ click.echo(f"Nodes touched: {len(nodes)}")
267
+ for status_name, count in summary["node_counts"].items():
268
+ click.echo(f" {status_name}: {count}")
269
+ if run.get("error_message"):
270
+ click.echo(f"Error: {run['error_message']}")
271
+
272
+ output.emit(summary, as_json)
273
+
274
+
275
+ @audit_group.command("delete")
276
+ @click.argument("run_id")
277
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
278
+ def delete(run_id: str, as_json: bool):
279
+ """Permanently delete an audit run and its node rows. ADMIN-ONLY.
280
+
281
+ Used to clean up one-off prospect audits so they don't pollute the
282
+ account's audit history. The backend enforces the `admin` app_role; a
283
+ non-admin key gets a 403.
284
+ """
285
+ result = client.invoke_function("delete-audit-run", {"runId": run_id}, timeout=30)
286
+ if not as_json:
287
+ deleted = result.get("deleted", {})
288
+ click.echo(f"Deleted audit run {deleted.get('audit_run', run_id)}")
289
+ if deleted.get("website_url"):
290
+ click.echo(f"Website: {deleted['website_url']}")
291
+ click.echo(f"Node rows: {deleted.get('node_rows', 0)}")
292
+ output.emit(result, as_json)
293
+
294
+
295
+ def _passed_counts(run_row: dict) -> tuple[int, int] | None:
296
+ """Return (passed, total) from audit_runs.audit_results, or None if absent.
297
+
298
+ ``audit_results`` is the v1-compatible check array (``[{passed, ...}]``);
299
+ tolerate a ``{"results": [...]}`` wrapper too. Returns None when the column
300
+ is empty/missing so callers can fall back to the node_progress breakdown.
301
+ """
302
+ ar = run_row.get("audit_results")
303
+ checks = ar if isinstance(ar, list) else (ar.get("results") if isinstance(ar, dict) else None)
304
+ if not checks:
305
+ return None
306
+ passed = sum(1 for c in checks if isinstance(c, dict) and c.get("passed"))
307
+ return passed, len(checks)
@@ -0,0 +1,175 @@
1
+ """blog — generate blog topics, draft posts, and content strategy.
2
+
3
+ Commands:
4
+ topics generate Generate blog topics for a profile (LLM-backed).
5
+ topics list Show currently tracked blog topics for a profile.
6
+ post generate Draft a full blog post from a topic (requires a CMS
7
+ connection; subject to a tier daily cap).
8
+ strategy Generate an overall content strategy for a profile.
9
+
10
+ Each subcommand wraps a single backend surface and supports ``--json``.
11
+ The backend authenticates ``rr_live_*`` keys via ``resolveApiKeyUserId``.
12
+ """
13
+
14
+ import click
15
+
16
+ from rankrush import client, edge, output
17
+ from rankrush.errors import MalformedResponseError
18
+ from rankrush.first_run import resolve_profile_id
19
+ from rankrush.options import profile_option
20
+
21
+ # Topic generation window. Backend defaults to 30 days; clamp client-side too
22
+ # so an out-of-range value never reaches the edge function.
23
+ _DEFAULT_DAYS = 30
24
+ _MAX_DAYS = 180
25
+
26
+
27
+ @click.group("blog")
28
+ def blog_group():
29
+ """Generate blog topics, draft posts, and content strategy."""
30
+
31
+
32
+ # ─── blog topics ────────────────────────────────────────────────────────────
33
+
34
+
35
+ @blog_group.group("topics")
36
+ def topics_group():
37
+ """Generate and list blog topics for a profile."""
38
+
39
+
40
+ @topics_group.command("generate")
41
+ @profile_option
42
+ @click.option(
43
+ "--days",
44
+ type=int,
45
+ default=_DEFAULT_DAYS,
46
+ show_default=True,
47
+ help=f"Planning window in days (1-{_MAX_DAYS}). Clamped to this range.",
48
+ )
49
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
50
+ def topics_generate(profile_id: str | None, business: str | None, days: int, as_json: bool):
51
+ """Generate blog topics for a profile over the next N days."""
52
+ profile_id = resolve_profile_id(profile_id, business)
53
+ days = max(1, min(days, _MAX_DAYS))
54
+
55
+ if not as_json:
56
+ click.echo(f"Generating blog topics ({days}-day window)...")
57
+
58
+ # invoke_function raises on any 4xx/5xx (incl. the backend's {error: ...}
59
+ # bodies), so a returned dict is always the 200 success payload. The
60
+ # success body is {generated, gapCount, calendarStart, calendarEnd,
61
+ # pillars, breakdown, layers, warning?} — there is no `success`/`topics` key.
62
+ data = client.invoke_function(
63
+ "generate-blog-topics",
64
+ {"profileId": profile_id, "days": days},
65
+ timeout=120,
66
+ )
67
+ if not isinstance(data, dict):
68
+ raise MalformedResponseError(
69
+ "generate-blog-topics returned an unexpected (non-object) response."
70
+ )
71
+
72
+ if not as_json:
73
+ click.echo(f"Generated {data.get('generated', 0)} topic(s).")
74
+ for p in data.get("pillars", []) or []:
75
+ name = p.get("name") if isinstance(p, dict) else p
76
+ click.echo(f" · {name}")
77
+ if data.get("warning"):
78
+ click.echo(data["warning"])
79
+
80
+ output.emit(data, as_json)
81
+
82
+
83
+ @topics_group.command("list")
84
+ @profile_option
85
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
86
+ def topics_list(profile_id: str | None, business: str | None, as_json: bool):
87
+ """List currently tracked blog topics for a profile."""
88
+ profile_id = resolve_profile_id(profile_id, business)
89
+ result = edge.blog_topics_list(profile_id)
90
+ rows = result["topics"]
91
+
92
+ if not as_json:
93
+ if not rows:
94
+ click.echo(
95
+ "No blog topics for this profile. "
96
+ "Run `rankrush blog topics generate` first."
97
+ )
98
+ else:
99
+ for r in rows:
100
+ stage = r.get("generation_stage") or "—"
101
+ when = r.get("scheduled_date") or "—"
102
+ click.echo(f" · {r.get('title')} [{stage}] scheduled: {when}")
103
+
104
+ output.emit(result, as_json)
105
+
106
+
107
+ # ─── blog post ──────────────────────────────────────────────────────────────
108
+
109
+
110
+ @blog_group.group("post")
111
+ def post_group():
112
+ """Draft blog posts from topics."""
113
+
114
+
115
+ @post_group.command("generate")
116
+ @profile_option
117
+ @click.option("--topic-id", required=True, help="Blog topic UUID to draft a post for.")
118
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
119
+ def post_generate(profile_id: str | None, business: str | None, topic_id: str, as_json: bool):
120
+ """Draft a full blog post from a topic.
121
+
122
+ Requires an existing CMS connection on the profile and is subject to a
123
+ per-tier daily cap. When either gate fails the backend returns an error
124
+ message which is surfaced cleanly via the CLI error boundary.
125
+ """
126
+ profile_id = resolve_profile_id(profile_id, business)
127
+
128
+ if not as_json:
129
+ click.echo(f"Drafting blog post for topic {topic_id}...")
130
+
131
+ # Business failures (connection_missing, daily_cap_reached, tier_too_low,
132
+ # topic_wrong_status, …) come back as HTTP 200 with {ok:false, code,
133
+ # message} — they do NOT trip invoke_function's status check, so gate on
134
+ # `ok` here and surface the backend's human message.
135
+ data = client.invoke_function(
136
+ "generate-blog-post",
137
+ {"profileId": profile_id, "topicId": topic_id},
138
+ timeout=120,
139
+ )
140
+ if not isinstance(data, dict) or not data.get("ok"):
141
+ msg = (
142
+ data.get("message") or data.get("error") or data.get("code") or "unknown"
143
+ if isinstance(data, dict)
144
+ else "unknown"
145
+ )
146
+ raise MalformedResponseError(f"generate-blog-post error: {msg}")
147
+
148
+ output.emit(data, as_json)
149
+
150
+
151
+ # ─── blog strategy ──────────────────────────────────────────────────────────
152
+
153
+
154
+ @blog_group.command("strategy")
155
+ @profile_option
156
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
157
+ def strategy(profile_id: str | None, business: str | None, as_json: bool):
158
+ """Generate an overall content strategy for a profile."""
159
+ profile_id = resolve_profile_id(profile_id, business)
160
+
161
+ if not as_json:
162
+ click.echo("Generating content strategy for this profile...")
163
+
164
+ # Success body is {ok:true, strategy}; non-200 errors already raise in
165
+ # invoke_function. Gate on `ok` defensively and surface any error message.
166
+ data = client.invoke_function(
167
+ "generate-content-strategy",
168
+ {"profileId": profile_id},
169
+ timeout=120,
170
+ )
171
+ if not isinstance(data, dict) or not data.get("ok"):
172
+ err = data.get("error", "unknown") if isinstance(data, dict) else "unknown"
173
+ raise MalformedResponseError(f"generate-content-strategy error: {err}")
174
+
175
+ output.emit(data, as_json)
File without changes
@@ -0,0 +1,84 @@
1
+ """buzz drafts — list and manage draft posts.
2
+
3
+ Commands:
4
+ list List posts filtered by status
5
+ approve Approve a draft for scheduling
6
+ """
7
+
8
+ import click
9
+
10
+ from rankrush import edge, output
11
+ from rankrush.first_run import resolve_profile_id
12
+ from rankrush.options import profile_option
13
+
14
+ VALID_STATUSES = ("draft", "approved", "scheduled", "posted", "failed")
15
+
16
+ # The exact columns the old direct-table read emitted, so `--json` is unchanged.
17
+ _DRAFT_FIELDS = (
18
+ "id", "profile_id", "status", "post_type", "subreddit",
19
+ "draft_content", "scheduled_at", "retry_count", "created_at",
20
+ )
21
+
22
+
23
+ @click.group("drafts")
24
+ def drafts_group():
25
+ """Manage Buzz draft posts."""
26
+
27
+
28
+ @drafts_group.command("list")
29
+ @profile_option
30
+ @click.option(
31
+ "--status",
32
+ default="draft",
33
+ show_default=True,
34
+ type=click.Choice(VALID_STATUSES),
35
+ help="Filter by post status.",
36
+ )
37
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
38
+ def list_drafts(profile_id: str | None, business: str | None, status: str, as_json: bool):
39
+ """List Buzz posts filtered by status (default: draft)."""
40
+ profile_id = resolve_profile_id(profile_id, business) if (profile_id or business) else None
41
+ engagements = edge.buzz_engagements_list(profile_id=profile_id, status=status)["engagements"]
42
+ rows = [{k: r.get(k) for k in _DRAFT_FIELDS} for r in engagements]
43
+
44
+ if not as_json:
45
+ if not rows:
46
+ if status == "draft":
47
+ click.echo(
48
+ "No draft posts yet.\n"
49
+ 'Generate some with `rankrush buzz strategy suggest --business "<name>"`.'
50
+ )
51
+ else:
52
+ click.echo(
53
+ f"No {status} posts yet.\n"
54
+ f"List drafts instead: `rankrush buzz drafts list --status draft`."
55
+ )
56
+ else:
57
+ click.echo(f"Found {len(rows)} {status} post(s):")
58
+
59
+ output.emit(rows, as_json)
60
+
61
+
62
+ @drafts_group.command("approve")
63
+ @click.option("--id", "draft_id", required=True, help="Draft post UUID to approve.")
64
+ @click.option(
65
+ "--schedule-at",
66
+ default=None,
67
+ help='ISO 8601 datetime to schedule posting (e.g. "2026-04-10T09:00:00Z"). '
68
+ "If omitted, the post is approved but not yet scheduled.",
69
+ )
70
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
71
+ def approve(draft_id: str, schedule_at: str | None, as_json: bool):
72
+ """Approve a draft post, optionally scheduling it for a specific time.
73
+
74
+ Updates buzz_engagements.status from 'draft' → 'approved' (or 'scheduled' if
75
+ --schedule-at is provided). The pg_cron scheduler will pick up 'scheduled'
76
+ posts and publish them via the Reddit API.
77
+ """
78
+ updated = edge.buzz_draft_approve(draft_id, schedule_at=schedule_at)
79
+
80
+ if not as_json:
81
+ ts = f" — scheduled for {schedule_at}" if schedule_at else ""
82
+ click.echo(f"Draft {draft_id} approved{ts}.")
83
+
84
+ output.emit(updated, as_json)
@@ -0,0 +1,97 @@
1
+ """buzz posts — inspect published posts and their performance."""
2
+
3
+ import click
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from rankrush import edge, output
7
+ from rankrush.errors import CliError
8
+ from rankrush.first_run import resolve_profile_id
9
+ from rankrush.options import profile_option
10
+
11
+ # The exact columns the old direct-table read emitted, so `--json` is unchanged.
12
+ _POST_FIELDS = (
13
+ "id", "profile_id", "status", "post_type",
14
+ "subreddit", "posted_at", "retry_count", "created_at",
15
+ )
16
+
17
+
18
+ @click.group("posts")
19
+ def posts_group():
20
+ """Inspect published Buzz posts."""
21
+
22
+
23
+ @posts_group.command("performance")
24
+ @profile_option
25
+ @click.option(
26
+ "--since",
27
+ default="7d",
28
+ show_default=True,
29
+ help="Lookback window: Nd (days) or YYYY-MM-DD date.",
30
+ )
31
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON output.")
32
+ def performance(profile_id: str | None, business: str | None, since: str, as_json: bool):
33
+ """Show published posts within a time window.
34
+
35
+ Queries buzz_engagements for posts with status='posted' within the lookback
36
+ window. Returns post metadata and basic status breakdown.
37
+
38
+ --since accepts either:
39
+ Nd — last N days (e.g. 7d, 30d)
40
+ ISO date - from this date forward (e.g. 2026-04-01)
41
+ """
42
+ profile_id = resolve_profile_id(profile_id, business) if (profile_id or business) else None
43
+
44
+ # Parse --since
45
+ since_dt: datetime
46
+ if since.endswith("d") and since[:-1].isdigit():
47
+ since_dt = datetime.now(timezone.utc) - timedelta(days=int(since[:-1]))
48
+ else:
49
+ try:
50
+ since_dt = datetime.fromisoformat(since).replace(tzinfo=timezone.utc)
51
+ except ValueError as exc:
52
+ raise CliError(
53
+ f"Invalid --since value {since!r}. Use Nd (e.g. 7d) or YYYY-MM-DD."
54
+ ) from exc
55
+
56
+ since_iso = since_dt.isoformat()
57
+
58
+ # Read all posted engagements via the edge function; window on posted_at
59
+ # client-side (as before - the server-side `since` filter is created_at-based
60
+ # and this view is posted_at-based). Project to the old column set so the
61
+ # `posts` payload is byte-for-byte identical to the pre-migration output.
62
+ engagements = edge.buzz_engagements_list(
63
+ profile_id=profile_id, status="posted", limit=200
64
+ )["engagements"]
65
+
66
+ in_window = [
67
+ {k: r.get(k) for k in _POST_FIELDS}
68
+ for r in engagements
69
+ if r.get("posted_at") and r["posted_at"] >= since_iso
70
+ ]
71
+
72
+ # Basic aggregation
73
+ by_subreddit: dict[str, int] = {}
74
+ for r in in_window:
75
+ sub = r.get("subreddit") or "unknown"
76
+ by_subreddit[sub] = by_subreddit.get(sub, 0) + 1
77
+
78
+ result = {
79
+ "window": since,
80
+ "total_posted": len(in_window),
81
+ "by_subreddit": dict(sorted(by_subreddit.items(), key=lambda x: -x[1])),
82
+ "posts": in_window,
83
+ }
84
+
85
+ if not as_json:
86
+ if not in_window:
87
+ click.echo(
88
+ f"No posts published in the last {since}.\n"
89
+ "Approve a draft to schedule one: "
90
+ "`rankrush buzz drafts approve --id <draft-id> --schedule-at <iso-time>`."
91
+ )
92
+ else:
93
+ click.echo(f"Posts published in the last {since}: {len(in_window)}")
94
+ for sub, count in list(result["by_subreddit"].items())[:5]:
95
+ click.echo(f" r/{sub}: {count}")
96
+
97
+ output.emit(result, as_json)