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.
- rankrush/__init__.py +3 -0
- rankrush/cache.py +83 -0
- rankrush/cli.py +241 -0
- rankrush/client.py +227 -0
- rankrush/commands/__init__.py +0 -0
- rankrush/commands/audit.py +307 -0
- rankrush/commands/blog.py +175 -0
- rankrush/commands/buzz/__init__.py +0 -0
- rankrush/commands/buzz/drafts.py +84 -0
- rankrush/commands/buzz/posts.py +97 -0
- rankrush/commands/buzz/research.py +108 -0
- rankrush/commands/buzz/strategy.py +47 -0
- rankrush/commands/cms.py +80 -0
- rankrush/commands/init.py +223 -0
- rankrush/commands/keywords.py +91 -0
- rankrush/commands/leadgen.py +125 -0
- rankrush/commands/leads.py +253 -0
- rankrush/commands/nodes.py +227 -0
- rankrush/commands/profiles.py +64 -0
- rankrush/commands/report.py +279 -0
- rankrush/commands/scans.py +88 -0
- rankrush/commands/smoke.py +147 -0
- rankrush/commands/visibility.py +354 -0
- rankrush/config.py +44 -0
- rankrush/edge.py +299 -0
- rankrush/env_file.py +140 -0
- rankrush/errors.py +119 -0
- rankrush/first_run.py +117 -0
- rankrush/leadgen/__init__.py +39 -0
- rankrush/leadgen/dedupe.py +135 -0
- rankrush/leadgen/footprints.py +156 -0
- rankrush/leadgen/pipeline.py +191 -0
- rankrush/leadgen/sampler.py +161 -0
- rankrush/leadgen/store.py +101 -0
- rankrush/leadgen/wat_parser.py +180 -0
- rankrush/mcp_server.py +363 -0
- rankrush/options.py +40 -0
- rankrush/output.py +56 -0
- rankrush_cli-0.1.37.dist-info/METADATA +301 -0
- rankrush_cli-0.1.37.dist-info/RECORD +146 -0
- rankrush_cli-0.1.37.dist-info/WHEEL +5 -0
- rankrush_cli-0.1.37.dist-info/entry_points.txt +3 -0
- rankrush_cli-0.1.37.dist-info/licenses/LICENSE +21 -0
- rankrush_cli-0.1.37.dist-info/top_level.txt +2 -0
- rankrush_client/__init__.py +232 -0
- rankrush_client/api/__init__.py +11 -0
- rankrush_client/api/admin_api.py +1181 -0
- rankrush_client/api/audit_api.py +1178 -0
- rankrush_client/api/blog_api.py +1467 -0
- rankrush_client/api/buzz_api.py +2322 -0
- rankrush_client/api/jwt_only_api.py +2021 -0
- rankrush_client/api/profiles_api.py +600 -0
- rankrush_client/api/visibility_api.py +898 -0
- rankrush_client/api_client.py +804 -0
- rankrush_client/api_response.py +21 -0
- rankrush_client/configuration.py +640 -0
- rankrush_client/exceptions.py +218 -0
- rankrush_client/models/__init__.py +102 -0
- rankrush_client/models/audit_enqueue_request.py +104 -0
- rankrush_client/models/audit_enqueue_response.py +112 -0
- rankrush_client/models/audit_run.py +142 -0
- rankrush_client/models/audit_run_get_request.py +91 -0
- rankrush_client/models/audit_run_get_response.py +104 -0
- rankrush_client/models/blog_topic.py +108 -0
- rankrush_client/models/blog_topics_list_request.py +92 -0
- rankrush_client/models/blog_topics_list_response.py +101 -0
- rankrush_client/models/buzz_brand_research_response.py +100 -0
- rankrush_client/models/buzz_brand_research_response_research_summary.py +106 -0
- rankrush_client/models/buzz_brand_research_response_research_summary_sentiment_breakdown.py +92 -0
- rankrush_client/models/buzz_brand_research_response_research_summary_top_subreddits_inner.py +90 -0
- rankrush_client/models/buzz_draft_approve_request.py +92 -0
- rankrush_client/models/buzz_engagement.py +135 -0
- rankrush_client/models/buzz_engagements_list_request.py +98 -0
- rankrush_client/models/buzz_engagements_list_response.py +100 -0
- rankrush_client/models/buzz_extract_profile_response.py +94 -0
- rankrush_client/models/buzz_extract_profile_response_extracted.py +98 -0
- rankrush_client/models/buzz_generate_drafts_request.py +102 -0
- rankrush_client/models/buzz_generate_drafts_response.py +114 -0
- rankrush_client/models/buzz_mention.py +134 -0
- rankrush_client/models/buzz_opportunity_search_response.py +96 -0
- rankrush_client/models/buzz_profile_get_response.py +104 -0
- rankrush_client/models/buzz_research_list_request.py +94 -0
- rankrush_client/models/buzz_research_list_response.py +101 -0
- rankrush_client/models/buzz_visibility_score_response.py +125 -0
- rankrush_client/models/buzz_visibility_score_response_engagement_metrics.py +96 -0
- rankrush_client/models/buzz_visibility_score_response_top_subreddits_inner.py +94 -0
- rankrush_client/models/buzz_wait_request.py +90 -0
- rankrush_client/models/cap_error.py +108 -0
- rankrush_client/models/check_ai_visibility_request.py +112 -0
- rankrush_client/models/check_ai_visibility_response.py +114 -0
- rankrush_client/models/check_ai_visibility_response_dropped_inner.py +92 -0
- rankrush_client/models/create_lead_account_request.py +105 -0
- rankrush_client/models/create_lead_account_response.py +109 -0
- rankrush_client/models/create_lead_account_response_scan.py +99 -0
- rankrush_client/models/delete_audit_run_request.py +88 -0
- rankrush_client/models/delete_audit_run_response.py +101 -0
- rankrush_client/models/delete_audit_run_response_deleted.py +92 -0
- rankrush_client/models/error.py +102 -0
- rankrush_client/models/extract_offerings_request.py +90 -0
- rankrush_client/models/extract_offerings_response.py +108 -0
- rankrush_client/models/generate_blog_post_request.py +93 -0
- rankrush_client/models/generate_blog_post_response.py +98 -0
- rankrush_client/models/generate_blog_topics_request.py +93 -0
- rankrush_client/models/generate_content_strategy_response.py +97 -0
- rankrush_client/models/generate_node_content_request.py +133 -0
- rankrush_client/models/generate_node_content_response.py +101 -0
- rankrush_client/models/generate_node_content_response_content.py +143 -0
- rankrush_client/models/generate_visibility_keywords_response.py +115 -0
- rankrush_client/models/generate_visibility_keywords_response_keywords_inner.py +100 -0
- rankrush_client/models/generate_visibility_keywords_response_sources.py +90 -0
- rankrush_client/models/gsc_fetch_queries_response.py +105 -0
- rankrush_client/models/gsc_fetch_queries_response_queries_inner.py +96 -0
- rankrush_client/models/gsc_select_property_request.py +90 -0
- rankrush_client/models/job_queued_response.py +109 -0
- rankrush_client/models/keywords_list_request.py +92 -0
- rankrush_client/models/keywords_list_response.py +93 -0
- rankrush_client/models/lead_enqueue_request.py +98 -0
- rankrush_client/models/lead_enqueue_response.py +114 -0
- rankrush_client/models/lead_enqueue_result.py +103 -0
- rankrush_client/models/lead_item.py +110 -0
- rankrush_client/models/node_complete_request.py +92 -0
- rankrush_client/models/node_complete_response.py +108 -0
- rankrush_client/models/node_progress.py +115 -0
- rankrush_client/models/node_progress_summary.py +97 -0
- rankrush_client/models/nodes_list_request.py +104 -0
- rankrush_client/models/nodes_list_response.py +101 -0
- rankrush_client/models/ok_response.py +95 -0
- rankrush_client/models/profile.py +122 -0
- rankrush_client/models/profile_id_camel_request.py +88 -0
- rankrush_client/models/profile_id_snake_request.py +88 -0
- rankrush_client/models/profile_update_request.py +104 -0
- rankrush_client/models/profiles_list_request.py +89 -0
- rankrush_client/models/rate_limit_error.py +112 -0
- rankrush_client/models/report_job_get_request.py +89 -0
- rankrush_client/models/report_job_get_response.py +142 -0
- rankrush_client/models/save_cms_connection_request.py +96 -0
- rankrush_client/models/save_cms_connection_response.py +99 -0
- rankrush_client/models/suggest_competitors_request.py +118 -0
- rankrush_client/models/suggest_competitors_response.py +107 -0
- rankrush_client/models/suggest_competitors_response_category.py +96 -0
- rankrush_client/models/suggest_competitors_response_competitors_inner.py +102 -0
- rankrush_client/models/validation_error_body.py +102 -0
- rankrush_client/models/verify_blog_posts_request.py +90 -0
- rankrush_client/models/verify_blog_posts_response.py +98 -0
- rankrush_client/py.typed +0 -0
- 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)
|