recala-cli 0.6.0__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.
- recala_cli/__init__.py +3 -0
- recala_cli/__main__.py +3 -0
- recala_cli/article_runs.py +372 -0
- recala_cli/auth.py +196 -0
- recala_cli/client.py +103 -0
- recala_cli/commands/__init__.py +1 -0
- recala_cli/commands/article.py +835 -0
- recala_cli/commands/auth.py +207 -0
- recala_cli/commands/billing.py +76 -0
- recala_cli/commands/bing.py +164 -0
- recala_cli/commands/cadence.py +217 -0
- recala_cli/commands/connect.py +395 -0
- recala_cli/commands/credits.py +46 -0
- recala_cli/commands/faithful_translations.py +70 -0
- recala_cli/commands/gsc.py +79 -0
- recala_cli/commands/index.py +293 -0
- recala_cli/commands/keys.py +98 -0
- recala_cli/commands/knowledge.py +245 -0
- recala_cli/commands/languages.py +119 -0
- recala_cli/commands/monthly_report.py +152 -0
- recala_cli/commands/notifications.py +106 -0
- recala_cli/commands/onboard.py +237 -0
- recala_cli/commands/org.py +352 -0
- recala_cli/commands/profile.py +70 -0
- recala_cli/commands/questions.py +246 -0
- recala_cli/commands/reads.py +320 -0
- recala_cli/commands/refresh.py +138 -0
- recala_cli/commands/refusals.py +20 -0
- recala_cli/commands/schema.py +47 -0
- recala_cli/commands/shell.py +81 -0
- recala_cli/commands/skills.py +92 -0
- recala_cli/commands/slot_topic.py +94 -0
- recala_cli/commands/status.py +153 -0
- recala_cli/commands/topics.py +328 -0
- recala_cli/config.py +176 -0
- recala_cli/confirm.py +137 -0
- recala_cli/display.py +40 -0
- recala_cli/endpoints.py +258 -0
- recala_cli/errors.py +220 -0
- recala_cli/human.py +324 -0
- recala_cli/ids.py +139 -0
- recala_cli/labels.py +74 -0
- recala_cli/main.py +129 -0
- recala_cli/onboarding.py +522 -0
- recala_cli/organizations.py +197 -0
- recala_cli/output.py +34 -0
- recala_cli/parallel.py +18 -0
- recala_cli/polling.py +102 -0
- recala_cli/prompts.py +88 -0
- recala_cli/runtime.py +216 -0
- recala_cli/search_numbers.py +22 -0
- recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
- recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
- recala_cli/skills/recala-cli-contract.md +78 -0
- recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
- recala_cli/skills/recala-onboard/SKILL.md +25 -0
- recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
- recala_cli/skills/recala-plan-month/SKILL.md +33 -0
- recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
- recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
- recala_cli/ui.py +260 -0
- recala_cli/updates.py +43 -0
- recala_cli/views.py +372 -0
- recala_cli-0.6.0.dist-info/METADATA +63 -0
- recala_cli-0.6.0.dist-info/RECORD +67 -0
- recala_cli-0.6.0.dist-info/WHEEL +4 -0
- recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""`recala connect`: where articles are published (the CMS) and how they are measured (Search Console).
|
|
2
|
+
|
|
3
|
+
Secrets never travel as arguments: a CMS token or an application password comes
|
|
4
|
+
from an environment variable, stdin or a hidden prompt and goes only into the request
|
|
5
|
+
body sent to Recala, which stores it encrypted. Search Console is connected with
|
|
6
|
+
Google's own sign-in in the browser (the CLI opens the link and waits), then the
|
|
7
|
+
property is chosen here; Recala never sees a Google password.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from recala_cli import config
|
|
21
|
+
from recala_cli.client import RecalaClient
|
|
22
|
+
from recala_cli.endpoints import (
|
|
23
|
+
CMS_STATUS,
|
|
24
|
+
GSC_PROPERTIES,
|
|
25
|
+
GSC_PROPERTY,
|
|
26
|
+
GSC_SERVICE_ACCOUNT,
|
|
27
|
+
GSC_SERVICE_ACCOUNT_CHECK,
|
|
28
|
+
GSC_STATUS,
|
|
29
|
+
ORG_SHOW,
|
|
30
|
+
PEOPLE_LIST,
|
|
31
|
+
SANITY_CONNECT,
|
|
32
|
+
SANITY_STATUS,
|
|
33
|
+
INDEXING_SITEMAPS,
|
|
34
|
+
WORDPRESS_CONNECT,
|
|
35
|
+
WORDPRESS_VERIFY,
|
|
36
|
+
)
|
|
37
|
+
from recala_cli.confirm import is_interactive
|
|
38
|
+
from recala_cli.errors import CliError, ExitCode
|
|
39
|
+
from recala_cli.polling import BROWSER_POLL_SECONDS, open_in_browser, poll_until
|
|
40
|
+
from recala_cli.prompts import ask, ask_optional, ask_secret, choose
|
|
41
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, sanity_or_none, accepted_yes_option, dry_run_option
|
|
42
|
+
from recala_cli.views import company_timezone, connection_lines, sitemap_lines
|
|
43
|
+
|
|
44
|
+
GSC_WAIT_SECONDS = 600
|
|
45
|
+
CMS_WAIT_SECONDS = 600
|
|
46
|
+
_GSC_CONNECTED = {"backfilling", "waiting_for_data", "live", "stale"}
|
|
47
|
+
SANITY_TOKEN_ENV = "RECALA_SANITY_TOKEN"
|
|
48
|
+
SANITY_HOOK_ENV = "RECALA_SANITY_BUILD_HOOK"
|
|
49
|
+
WP_PASSWORD_ENV = "RECALA_WP_APP_PASSWORD"
|
|
50
|
+
SERVICE_ACCOUNT_OFF = ("The service-account method is not enabled on this Recala server. Ask Recala support to enable "
|
|
51
|
+
"it, or use: recala connect gsc --oauth")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def organization(client: RecalaClient) -> dict[str, Any]:
|
|
55
|
+
shown = client.call(ORG_SHOW, path={"org_id": str(client.org_id)}) or {}
|
|
56
|
+
return dict(shown.get("org") or {})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_connections(client: RecalaClient, *, with_sitemap: bool = True) -> dict[str, Any]:
|
|
60
|
+
status = {"cms": optional_read(client, CMS_STATUS), "search_console": optional_read(client, GSC_STATUS),
|
|
61
|
+
"org": organization(client)}
|
|
62
|
+
if (status["cms"] or {}).get("provider") == "Sanity":
|
|
63
|
+
status["sanity"] = sanity_or_none(client)
|
|
64
|
+
if with_sitemap:
|
|
65
|
+
status["sitemap"] = _sitemap_or_none(client)
|
|
66
|
+
return status
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sitemap_or_none(client: RecalaClient) -> dict[str, Any] | None:
|
|
70
|
+
"""Sitemaps live in Search Console: without a connection (409) there is nothing to show, not a failure."""
|
|
71
|
+
try:
|
|
72
|
+
return optional_read(client, INDEXING_SITEMAPS) # type: ignore[no-any-return]
|
|
73
|
+
except CliError as error:
|
|
74
|
+
if error.exit_code == ExitCode.CONFLICT:
|
|
75
|
+
return None
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@click.group()
|
|
80
|
+
def connect() -> None:
|
|
81
|
+
"""Connections: the company's CMS and Google Search Console."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@connect.command("status")
|
|
85
|
+
@common_options
|
|
86
|
+
@command_runner(contextual_human=lambda ctx, s: "\n".join(connection_lines(s, zone=company_timezone(ctx, s))))
|
|
87
|
+
def status(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
88
|
+
"""CMS, Search Console and sitemap: next steps.
|
|
89
|
+
|
|
90
|
+
The CMS (its endpoint and the public site), Search Console and the sitemap, with the next step for each."""
|
|
91
|
+
return read_connections(open_client(ctx, org_flag))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Sanity ──────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def suggested_blog(org: dict[str, Any]) -> str | None:
|
|
98
|
+
"""The blog the website scan found, else the website's /blog."""
|
|
99
|
+
if org.get("blog_base_url"):
|
|
100
|
+
return str(org["blog_base_url"]).rstrip("/")
|
|
101
|
+
website = str(org.get("website_url") or "").rstrip("/")
|
|
102
|
+
return f"{website}/blog" if website else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sanity_author_map(ctx: click.Context, client: RecalaClient, pairs: tuple[str, ...]) -> dict[str, str]:
|
|
106
|
+
"""``PERSON=SANITY_AUTHOR_ID`` pairs, else ask for each author's Sanity author document ID."""
|
|
107
|
+
if pairs:
|
|
108
|
+
mapping = dict(pair.split("=", 1) for pair in pairs if "=" in pair)
|
|
109
|
+
if len(mapping) != len(pairs):
|
|
110
|
+
raise CliError(ExitCode.USAGE, "invalid_author", "--author takes PERSON_ID=SANITY_AUTHOR_ID.")
|
|
111
|
+
return mapping
|
|
112
|
+
people = (client.call(PEOPLE_LIST) or {}).get("people") or []
|
|
113
|
+
if not people:
|
|
114
|
+
raise CliError(ExitCode.CONFLICT, "no_authors", "Add an author first: recala authors add --name ... --title ...")
|
|
115
|
+
mapping = {}
|
|
116
|
+
for person in people:
|
|
117
|
+
answer = ask_optional(ctx, None, label=f"Sanity author document ID for {person.get('display_name')}")
|
|
118
|
+
if answer:
|
|
119
|
+
mapping[str(person["id"])] = answer
|
|
120
|
+
if not mapping:
|
|
121
|
+
raise CliError(ExitCode.USAGE, "missing_input",
|
|
122
|
+
"Map at least one author: --author PERSON_ID=SANITY_AUTHOR_ID (the author's document ID in Studio).")
|
|
123
|
+
return mapping
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@connect.command("sanity")
|
|
127
|
+
@common_options
|
|
128
|
+
@command_runner(human=lambda r: f"Sanity connected: project {r.get('project_id')}, dataset {r.get('dataset')}.")
|
|
129
|
+
@click.option("--project", "project_id", default=None, help="Sanity project ID.")
|
|
130
|
+
@click.option("--dataset", default=None, help="Dataset (default production).")
|
|
131
|
+
@click.option("--schema-profile", type=click.Choice(["post", "article"]), default=None,
|
|
132
|
+
help="Sanity document schema (default post; prompts when interactive).")
|
|
133
|
+
@click.option("--blog-url", default=None, help="The public HTTPS address where articles appear.")
|
|
134
|
+
@click.option("--author", "authors", multiple=True, help="PERSON_ID=SANITY_AUTHOR_ID. Repeat for each author.")
|
|
135
|
+
@click.option("--token-stdin", is_flag=True, help="Read the CMS secret from one stdin line; never echoes it.")
|
|
136
|
+
@accepted_yes_option
|
|
137
|
+
@dry_run_option
|
|
138
|
+
def sanity(ctx: click.Context, org_flag: str | None, project_id: str | None, dataset: str | None, blog_url: str | None,
|
|
139
|
+
authors: tuple[str, ...], token_stdin: bool, schema_profile: str | None) -> dict[str, Any]:
|
|
140
|
+
"""Publish to Sanity. The write token comes from RECALA_SANITY_TOKEN, --token-stdin or a hidden prompt."""
|
|
141
|
+
return connect_sanity(ctx, open_client(ctx, org_flag), project_id=project_id, dataset=dataset, blog_url=blog_url,
|
|
142
|
+
authors=authors, token_stdin=token_stdin, schema_profile=schema_profile)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def connect_sanity(ctx: click.Context, client: RecalaClient, *, project_id: str | None = None,
|
|
146
|
+
dataset: str | None = None, blog_url: str | None = None,
|
|
147
|
+
authors: tuple[str, ...] = (), token_stdin: bool = False,
|
|
148
|
+
schema_profile: str | None = None) -> dict[str, Any]:
|
|
149
|
+
org = organization(client)
|
|
150
|
+
body: dict[str, Any] = {
|
|
151
|
+
"project_id": ask(ctx, project_id, label="Sanity project ID", flag="--project"),
|
|
152
|
+
"dataset": ask(ctx, dataset, label="Dataset", flag="--dataset", default="production"),
|
|
153
|
+
"blog_url": ask(ctx, blog_url, label="Public blog address (https)", flag="--blog-url",
|
|
154
|
+
default=suggested_blog(org) if is_interactive(ctx) and not blog_url else None),
|
|
155
|
+
"author_map": sanity_author_map(ctx, client, authors),
|
|
156
|
+
"schema_profile": choose(ctx, [("post", "post: documents with a status field"), ("article", "article: documents without a status field")],
|
|
157
|
+
label="Schema profile", flag="--schema-profile", value=schema_profile,
|
|
158
|
+
default="post"),
|
|
159
|
+
}
|
|
160
|
+
token = ask_secret(ctx, label="Sanity write token", env=SANITY_TOKEN_ENV, token_stdin=token_stdin)
|
|
161
|
+
hook = ask_secret_optional(SANITY_HOOK_ENV)
|
|
162
|
+
confirm(ctx, client, f"Connect Sanity project {body['project_id']} ({body['dataset']}) to publish on "
|
|
163
|
+
f"{body['blog_url']}?")
|
|
164
|
+
connected: dict[str, Any] = client.call(SANITY_CONNECT, json={**body, "token": token,
|
|
165
|
+
**({"build_hook_url": hook} if hook else {})})
|
|
166
|
+
return {**connected, "schema_profile": body["schema_profile"]}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ask_secret_optional(env: str) -> str:
|
|
170
|
+
"""An optional secret: only ever from the environment."""
|
|
171
|
+
return os.environ.get(env, "").strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── WordPress ───────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _wordpress_com_address(ctx: click.Context, site: str, wpcom_address: str | None) -> str:
|
|
178
|
+
"""A WordPress.com site behind its own domain (halconia.es) connects through its *.wordpress.com address."""
|
|
179
|
+
if ".wordpress.com" in site:
|
|
180
|
+
return site
|
|
181
|
+
return ask(ctx, wpcom_address, label=f"The WordPress.com address of {site} (e.g. yoursite.wordpress.com)",
|
|
182
|
+
flag="--wpcom-address")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@connect.command("wordpress")
|
|
186
|
+
@common_options
|
|
187
|
+
@command_runner(human=lambda r: f"WordPress connected ({r.get('mode')}): {r.get('site_url') or r.get('blog_url')}.")
|
|
188
|
+
@click.option("--site", default=None, help="The WordPress site address.")
|
|
189
|
+
@click.option("--user", "user_login", default=None, help="Self-hosted: the WordPress user that publishes.")
|
|
190
|
+
@click.option("--wordpress-com", "wordpress_com", is_flag=True, help="The site is hosted on WordPress.com "
|
|
191
|
+
"(also behind your own domain).")
|
|
192
|
+
@click.option("--wpcom-address", default=None, help="With --wordpress-com and your own domain: the *.wordpress.com address.")
|
|
193
|
+
@click.option("--token-stdin", is_flag=True, help="Read the application password from one stdin line; never echoes it.")
|
|
194
|
+
@accepted_yes_option
|
|
195
|
+
@dry_run_option
|
|
196
|
+
def wordpress(ctx: click.Context, org_flag: str | None, site: str | None, user_login: str | None, wordpress_com: bool,
|
|
197
|
+
wpcom_address: str | None, token_stdin: bool) -> dict[str, Any]:
|
|
198
|
+
"""Publish to WordPress.
|
|
199
|
+
|
|
200
|
+
Self-hosted with an application password
|
|
201
|
+
(RECALA_WP_APP_PASSWORD, --token-stdin or a hidden prompt),
|
|
202
|
+
or WordPress.com with its sign-in in the browser."""
|
|
203
|
+
return connect_wordpress(ctx, open_client(ctx, org_flag), site=site, user_login=user_login,
|
|
204
|
+
wordpress_com=wordpress_com, wpcom_address=wpcom_address, token_stdin=token_stdin)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def connect_wordpress(ctx: click.Context, client: RecalaClient, *, site: str | None = None,
|
|
208
|
+
user_login: str | None = None, wordpress_com: bool = False,
|
|
209
|
+
wpcom_address: str | None = None, token_stdin: bool = False) -> dict[str, Any]:
|
|
210
|
+
org = organization(client)
|
|
211
|
+
address = ask(ctx, site, label="WordPress site address", flag="--site", default=org.get("website_url") or None)
|
|
212
|
+
if wordpress_com:
|
|
213
|
+
address = _wordpress_com_address(ctx, address, wpcom_address)
|
|
214
|
+
confirm(ctx, client, f"Connect the WordPress site {address}?")
|
|
215
|
+
verified = client.call(WORDPRESS_VERIFY, json={"url": address, "return_path": "/portal/settings"}) or {}
|
|
216
|
+
if not verified.get("available"):
|
|
217
|
+
hint = (" If it is a WordPress.com site behind your own domain, run: recala connect wordpress --wordpress-com"
|
|
218
|
+
if not wordpress_com else "")
|
|
219
|
+
raise CliError(ExitCode.CONFLICT, "wordpress_unavailable", f"{verified.get('error') or 'Not available.'}{hint}")
|
|
220
|
+
if verified.get("mode") == "wpcom":
|
|
221
|
+
return _connect_wordpress_com(ctx, client, verified, public_site=org.get("website_url") or "")
|
|
222
|
+
login = ask(ctx, user_login, label="WordPress user", flag="--user")
|
|
223
|
+
password = ask_secret(ctx, label="Application password (WordPress → Users → Profile)",
|
|
224
|
+
env=WP_PASSWORD_ENV, token_stdin=token_stdin)
|
|
225
|
+
connected: dict[str, Any] = client.call(WORDPRESS_CONNECT, json={
|
|
226
|
+
"site_url": address, "user_login": login, "password": password,
|
|
227
|
+
"return_context": verified.get("return_context")})
|
|
228
|
+
return connected
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _connect_wordpress_com(ctx: click.Context, client: RecalaClient, verified: dict[str, Any], *,
|
|
232
|
+
public_site: str) -> dict[str, Any]:
|
|
233
|
+
open_in_browser(ctx, verified["auth_url"], why="Approve Recala on WordPress.com in your browser:")
|
|
234
|
+
cms = poll_until(ctx, lambda: client.call(CMS_STATUS) or {}, lambda c: bool(c.get("connected")),
|
|
235
|
+
label="Waiting for WordPress.com approval in your browser", timeout_seconds=CMS_WAIT_SECONDS,
|
|
236
|
+
interval_seconds=BROWSER_POLL_SECONDS, outside_ticks=False,
|
|
237
|
+
done=lambda c: f"WordPress.com connected: {c.get('site_url')}")
|
|
238
|
+
return {"mode": "wpcom", "blog_url": cms.get("site_url"), "public_site": public_site, "connected": True}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── Search Console ──────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def suggested_property(properties: list[dict[str, Any]], website: str) -> str | None:
|
|
245
|
+
"""The domain property for the company's own site first, then a URL-prefix property of it."""
|
|
246
|
+
host = (urlsplit(website if "//" in website else f"https://{website}").hostname or "").removeprefix("www.")
|
|
247
|
+
if not host:
|
|
248
|
+
return None
|
|
249
|
+
ranked = [p["site_url"] for p in properties if p.get("site_url") == f"sc-domain:{host}"]
|
|
250
|
+
ranked += [p["site_url"] for p in properties
|
|
251
|
+
if (urlsplit(p.get("site_url", "")).hostname or "").removeprefix("www.") == host]
|
|
252
|
+
return ranked[0] if ranked else None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def connect_search_console(ctx: click.Context, client: RecalaClient, *, property_choice: str | None,
|
|
256
|
+
force_oauth: bool = False) -> dict[str, Any]:
|
|
257
|
+
"""Google sign-in in the browser (if needed), then the property, chosen here."""
|
|
258
|
+
current = client.call(GSC_STATUS) or {}
|
|
259
|
+
if force_oauth or current.get("state") in ("not_connected", "needs_reconnect", None):
|
|
260
|
+
link = f"{config.web_url()}/portal/settings?gsc=connect&org={client.org_id}"
|
|
261
|
+
open_in_browser(ctx, link, why="Sign in with Google in your browser to connect Search Console:")
|
|
262
|
+
current = poll_until(ctx, lambda: client.call(GSC_STATUS) or {},
|
|
263
|
+
lambda s: s.get("state") not in ("not_connected", "needs_reconnect", None),
|
|
264
|
+
label="Waiting for Google sign-in in your browser", timeout_seconds=GSC_WAIT_SECONDS,
|
|
265
|
+
interval_seconds=BROWSER_POLL_SECONDS, outside_ticks=False,
|
|
266
|
+
done=lambda s: "Google account connected")
|
|
267
|
+
if current.get("state") == "connecting" or (property_choice and property_choice != current.get("property")):
|
|
268
|
+
current = choose_property(ctx, client, property_choice)
|
|
269
|
+
return current
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def choose_property(ctx: click.Context, client: RecalaClient, property_choice: str | None) -> dict[str, Any]:
|
|
273
|
+
listed = client.call(GSC_PROPERTIES) or {}
|
|
274
|
+
properties = listed.get("properties") or []
|
|
275
|
+
if not properties:
|
|
276
|
+
raise CliError(ExitCode.CONFLICT, "no_properties",
|
|
277
|
+
"This Google account has no verified Search Console property. Verify the site in Search Console, "
|
|
278
|
+
"then run: recala connect gsc")
|
|
279
|
+
website = organization(client).get("website_url") or ""
|
|
280
|
+
chosen = choose(ctx, [(p["site_url"], f"{p['site_url']} ({p.get('permission_level')})") for p in properties],
|
|
281
|
+
label="Which Search Console property is this company's site?", flag="--property",
|
|
282
|
+
value=property_choice,
|
|
283
|
+
default=listed.get("suggested_property") or suggested_property(properties, website))
|
|
284
|
+
confirm(ctx, client, f"Use the Search Console property {chosen}?")
|
|
285
|
+
client.call(GSC_PROPERTY, json={"property": chosen})
|
|
286
|
+
answer: dict[str, Any] = client.call(GSC_STATUS) or {}
|
|
287
|
+
return answer
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _describe_gsc(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
291
|
+
zone = company_timezone(ctx, result)
|
|
292
|
+
lines = connection_lines({"search_console": result.get("search_console"), "cms": result.get("cms"),
|
|
293
|
+
"org": result.get("org")}, zone=zone)[-2:] if result.get("search_console") else []
|
|
294
|
+
if result.get("service_account"):
|
|
295
|
+
account = result["service_account"]
|
|
296
|
+
lines = [account.get("instruction") or f"Add {account.get('email')} as a Restricted user."]
|
|
297
|
+
if result.get("sitemap"):
|
|
298
|
+
lines.extend(sitemap_lines(result["sitemap"], zone))
|
|
299
|
+
return "\n".join(lines) or str(result)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@connect.command("gsc")
|
|
303
|
+
@common_options
|
|
304
|
+
@command_runner(contextual_human=_describe_gsc)
|
|
305
|
+
@click.option("--oauth", "force_oauth", is_flag=True, help="Sign in with Google in the browser (the default path).")
|
|
306
|
+
@click.option("--property", "property_choice", default=None, help="The property to use, e.g. sc-domain:example.com.")
|
|
307
|
+
@click.option("--service-account", "how", is_flag=True, help="Instead: the email to add as a Restricted user.")
|
|
308
|
+
@click.option("--check", "prop", default=None, help="With the service account: connect this property once it was added.")
|
|
309
|
+
@accepted_yes_option
|
|
310
|
+
@dry_run_option
|
|
311
|
+
def gsc(ctx: click.Context, org_flag: str | None, force_oauth: bool, property_choice: str | None, how: bool,
|
|
312
|
+
prop: str | None) -> dict[str, Any]:
|
|
313
|
+
"""Connect Google Search Console.
|
|
314
|
+
|
|
315
|
+
Google sign-in in the browser, then choose the property."""
|
|
316
|
+
if how and prop:
|
|
317
|
+
raise click.UsageError("Choose --service-account or --check <property>.")
|
|
318
|
+
client = open_client(ctx, org_flag)
|
|
319
|
+
if how:
|
|
320
|
+
account = _service_account(client)
|
|
321
|
+
if account is not None:
|
|
322
|
+
return {"service_account": account}
|
|
323
|
+
if not is_interactive(ctx) or not click.confirm(f" {SERVICE_ACCOUNT_OFF}\n Connect with Google sign-in now?",
|
|
324
|
+
default=True, err=True):
|
|
325
|
+
raise CliError(ExitCode.CONFLICT, "service_account_not_configured", SERVICE_ACCOUNT_OFF, 503)
|
|
326
|
+
force_oauth = True
|
|
327
|
+
if prop:
|
|
328
|
+
confirm(ctx, client, f"Connect {prop} through Recala's service account?")
|
|
329
|
+
checked: dict[str, Any] = client.call(GSC_SERVICE_ACCOUNT_CHECK, json={"property": prop})
|
|
330
|
+
return {"search_console": checked}
|
|
331
|
+
connected = connect_search_console(ctx, client, property_choice=property_choice, force_oauth=force_oauth)
|
|
332
|
+
return {"search_console": connected}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _service_account(client: RecalaClient) -> dict[str, Any] | None:
|
|
336
|
+
"""The service account's email, or None when the server has no service account configured (a 503)."""
|
|
337
|
+
try:
|
|
338
|
+
answer: dict[str, Any] = client.call(GSC_SERVICE_ACCOUNT)
|
|
339
|
+
return answer
|
|
340
|
+
except CliError as error:
|
|
341
|
+
if error.status != 503:
|
|
342
|
+
raise
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@connect.command("sitemap", hidden=True) # same view as `recala index sitemaps`
|
|
347
|
+
@common_options
|
|
348
|
+
@command_runner(contextual_human=lambda ctx, r: "\n".join(sitemap_lines(r, company_timezone(ctx, r))))
|
|
349
|
+
def sitemap(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
350
|
+
"""The site's sitemaps in Search Console.
|
|
351
|
+
|
|
352
|
+
Health, errors and last read."""
|
|
353
|
+
answer: dict[str, Any] = open_client(ctx, org_flag).call(INDEXING_SITEMAPS)
|
|
354
|
+
return answer
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@click.group()
|
|
358
|
+
def cms() -> None:
|
|
359
|
+
"""The CMS where the company publishes."""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@cms.command("connect")
|
|
363
|
+
@common_options
|
|
364
|
+
@command_runner(human=lambda r: f"CMS connected: {r.get('provider') or r.get('cms_type') or r.get('mode') or 'Sanity'}.")
|
|
365
|
+
@click.option("--provider", type=click.Choice(["sanity", "wordpress"]), default=None)
|
|
366
|
+
@click.option("--project", "project_id", default=None)
|
|
367
|
+
@click.option("--dataset", default=None)
|
|
368
|
+
@click.option("--schema-profile", type=click.Choice(["post", "article"]), default=None,
|
|
369
|
+
help="Sanity document schema (default post; prompts when interactive).")
|
|
370
|
+
@click.option("--blog-url", default=None)
|
|
371
|
+
@click.option("--author", "authors", multiple=True, help="PERSON_ID=SANITY_AUTHOR_ID.")
|
|
372
|
+
@click.option("--site", default=None)
|
|
373
|
+
@click.option("--user", "user_login", default=None)
|
|
374
|
+
@click.option("--wordpress-com", is_flag=True)
|
|
375
|
+
@click.option("--wpcom-address", default=None)
|
|
376
|
+
@click.option("--token-stdin", is_flag=True, help="Read the CMS token/password from one stdin line; never echoes it.")
|
|
377
|
+
@accepted_yes_option
|
|
378
|
+
@dry_run_option
|
|
379
|
+
def connect_cms(ctx: click.Context, org_flag: str | None, provider: str | None, project_id: str | None,
|
|
380
|
+
dataset: str | None, blog_url: str | None, authors: tuple[str, ...], site: str | None,
|
|
381
|
+
user_login: str | None, wordpress_com: bool, wpcom_address: str | None, token_stdin: bool,
|
|
382
|
+
schema_profile: str | None) -> dict[str, Any]:
|
|
383
|
+
"""Connect Sanity or WordPress. Credentials come from env, --token-stdin or a hidden human prompt."""
|
|
384
|
+
client = open_client(ctx, org_flag)
|
|
385
|
+
provider = choose(ctx, [("sanity", "Sanity"), ("wordpress", "WordPress")], label="CMS provider",
|
|
386
|
+
flag="--provider", value=provider)
|
|
387
|
+
if provider == "sanity":
|
|
388
|
+
return connect_sanity(ctx, client, project_id=project_id, dataset=dataset, blog_url=blog_url,
|
|
389
|
+
authors=authors, token_stdin=token_stdin, schema_profile=schema_profile)
|
|
390
|
+
return connect_wordpress(ctx, client, site=site, user_login=user_login, wordpress_com=wordpress_com,
|
|
391
|
+
wpcom_address=wpcom_address, token_stdin=token_stdin)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
cms.add_command(copy.copy(status), name="status") # same view as `recala connect status`
|
|
395
|
+
cms.commands["status"].hidden = True
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""`recala credits`: everything that limits spending, before any paid action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from recala_cli.client import RecalaClient
|
|
10
|
+
from recala_cli.endpoints import (
|
|
11
|
+
BILLING_CREDITS,
|
|
12
|
+
BILLING_MANAGED_USAGE,
|
|
13
|
+
BILLING_SPEND,
|
|
14
|
+
BILLING_SUBSCRIPTION,
|
|
15
|
+
CREDITS_SUMMARY,
|
|
16
|
+
ENTITLEMENTS,
|
|
17
|
+
)
|
|
18
|
+
from recala_cli.runtime import command_runner, common_options, open_client, optional_read
|
|
19
|
+
from recala_cli.views import describe_credits
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_credits(client: RecalaClient) -> dict[str, Any]:
|
|
23
|
+
"""The balance, the plan, the managed cap, the portal's summary (plan, left, reset) and the plan's caps."""
|
|
24
|
+
entitlements = optional_read(client, ENTITLEMENTS)
|
|
25
|
+
return {
|
|
26
|
+
"credits": client.call(BILLING_CREDITS),
|
|
27
|
+
"subscription": client.call(BILLING_SUBSCRIPTION),
|
|
28
|
+
"managed_usage": client.call(BILLING_MANAGED_USAGE),
|
|
29
|
+
"summary": optional_read(client, CREDITS_SUMMARY),
|
|
30
|
+
"entitlements": entitlements if (entitlements or {}).get("enabled") else None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.command()
|
|
35
|
+
@common_options
|
|
36
|
+
@command_runner(human=describe_credits)
|
|
37
|
+
@click.option("--spend", is_flag=True, help="Also Recala's provider spend this month and today (owners).")
|
|
38
|
+
def credits(ctx: click.Context, org_flag: str | None, spend: bool) -> dict[str, Any]:
|
|
39
|
+
"""Plan, articles left, reset date, extras, caps.
|
|
40
|
+
|
|
41
|
+
Unlimited when no plan caps apply."""
|
|
42
|
+
client = open_client(ctx, org_flag)
|
|
43
|
+
answer = read_credits(client)
|
|
44
|
+
if spend:
|
|
45
|
+
answer["provider_spend"] = client.call(BILLING_SPEND)
|
|
46
|
+
return answer
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Explicit owner consent for automatic approval of verified faithful translations."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from recala_cli import auth
|
|
6
|
+
from recala_cli.endpoints import FAITHFUL_TRANSLATIONS_SET, FAITHFUL_TRANSLATIONS_SHOW
|
|
7
|
+
from recala_cli.errors import CliError, ExitCode
|
|
8
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option
|
|
9
|
+
|
|
10
|
+
SETTING = "auto_approve_faithful_translations"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _owner_session_client(ctx, org_flag):
|
|
14
|
+
if auth.credential_kind() == "api_key":
|
|
15
|
+
raise CliError(
|
|
16
|
+
ExitCode.FORBIDDEN, "human_only",
|
|
17
|
+
"This setting requires a signed-in company owner. Run: recala login",
|
|
18
|
+
)
|
|
19
|
+
return open_client(ctx, org_flag)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _describe_setting(result):
|
|
23
|
+
state = "on" if result[SETTING] else "off"
|
|
24
|
+
prefix = "Dry run — " if result.get("dry_run") else ""
|
|
25
|
+
return f"{prefix}Faithful translation auto-approval: {state}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group("faithful-translations")
|
|
29
|
+
def faithful_translations():
|
|
30
|
+
"""Auto-approve verified translations (owners).
|
|
31
|
+
|
|
32
|
+
Ordinary auto-approve is also required."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@faithful_translations.command("show")
|
|
36
|
+
@common_options
|
|
37
|
+
@command_runner(human=_describe_setting)
|
|
38
|
+
def show(ctx, org_flag):
|
|
39
|
+
"""Show the saved faithful-translation approval setting."""
|
|
40
|
+
return _owner_session_client(ctx, org_flag).call(FAITHFUL_TRANSLATIONS_SHOW)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _change_setting(ctx, org_flag, enabled, dry_run):
|
|
44
|
+
client = _owner_session_client(ctx, org_flag)
|
|
45
|
+
if dry_run:
|
|
46
|
+
return {"dry_run": True, SETTING: enabled}
|
|
47
|
+
confirm(ctx, client, f"Turn faithful translation auto-approval {'on' if enabled else 'off'}?")
|
|
48
|
+
return client.call(FAITHFUL_TRANSLATIONS_SET, json={"enabled": enabled})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@faithful_translations.command("enable")
|
|
52
|
+
@common_options
|
|
53
|
+
@command_runner(human=_describe_setting)
|
|
54
|
+
@click.option("--dry-run", is_flag=True, help="Preview the setting without saving.")
|
|
55
|
+
@accepted_yes_option
|
|
56
|
+
def enable(ctx, org_flag, dry_run):
|
|
57
|
+
"""Opt into auto-approving verified translations.
|
|
58
|
+
|
|
59
|
+
Approval happens after the source and translation checks pass."""
|
|
60
|
+
return _change_setting(ctx, org_flag, True, dry_run)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@faithful_translations.command("disable")
|
|
64
|
+
@common_options
|
|
65
|
+
@command_runner(human=_describe_setting)
|
|
66
|
+
@click.option("--dry-run", is_flag=True, help="Preview the setting without saving.")
|
|
67
|
+
@accepted_yes_option
|
|
68
|
+
def disable(ctx, org_flag, dry_run):
|
|
69
|
+
"""Turn off the faithful-translation approval opt-in."""
|
|
70
|
+
return _change_setting(ctx, org_flag, False, dry_run)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Google Search Console: read the stored numbers (connect with `recala connect gsc`).
|
|
2
|
+
|
|
3
|
+
The numbers come from what Recala has stored, never from a live call to Google, so exploring them costs nothing. Only
|
|
4
|
+
Google's final days are counted. A company with no numbers gets its state in words, never a zero.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from recala_cli.endpoints import GSC_PERFORMANCE
|
|
14
|
+
from recala_cli.errors import CliError, ExitCode
|
|
15
|
+
from recala_cli.runtime import command_runner, common_options, open_client
|
|
16
|
+
from recala_cli.search_numbers import ranked_lines, totals_sentence
|
|
17
|
+
|
|
18
|
+
SWITCHED_OFF = "Google Search Console isn't switched on for this company yet."
|
|
19
|
+
#: Like `recala bing data`, these fail: there is a step for the person to take.
|
|
20
|
+
_NEEDS_A_STEP = {
|
|
21
|
+
"not_connected": ("gsc_not_connected", "Google Search Console isn't connected for this company. Connect it: recala connect gsc"),
|
|
22
|
+
"connecting": ("gsc_not_connected", "Signed in to Google. Choose the property: recala connect gsc"),
|
|
23
|
+
"needs_reconnect": ("gsc_needs_reconnect", "Google access was lost. Reconnect: recala connect gsc"),
|
|
24
|
+
}
|
|
25
|
+
_NO_NUMBERS_YET = {
|
|
26
|
+
"backfilling": "{site}: first import running.",
|
|
27
|
+
"pending_review": "{site}: Recala is checking that this property is the company's. Numbers appear once that is done.",
|
|
28
|
+
"waiting_for_data": "{site}: connected, and Google has no search data for it in this period.",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
def gsc() -> None:
|
|
34
|
+
"""Google Search Console: its search numbers."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _this_period(block: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
38
|
+
"""A key with no impressions in this period is absent from it, not a result of zero."""
|
|
39
|
+
rows = (block or {}).get("rows") or []
|
|
40
|
+
return [{"key": row["key"], **row["current"]} for row in rows if row["current"]["impressions"]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _describe_performance(data: dict[str, Any]) -> str:
|
|
44
|
+
source = data.get("source") or {}
|
|
45
|
+
site = source.get("property")
|
|
46
|
+
totals = data.get("totals")
|
|
47
|
+
if not totals:
|
|
48
|
+
fallback = "{site}: no Search Console numbers stored for this period yet."
|
|
49
|
+
return _NO_NUMBERS_YET.get(source.get("state") or "", fallback).format(site=site)
|
|
50
|
+
queries = data.get("queries") or {}
|
|
51
|
+
period = (queries.get("window") or {}).get("current") or {}
|
|
52
|
+
lines = [f"{site}, {period.get('start')} to {period.get('end')}",
|
|
53
|
+
f"{totals_sentence(totals)}. Final data through {source.get('final_through')}."]
|
|
54
|
+
if source.get("stale_since"):
|
|
55
|
+
lines.append(f"Updates stopped on {source['stale_since']}.")
|
|
56
|
+
lines += ranked_lines("Top queries", _this_period(queries)) + ranked_lines("Top pages", _this_period(data.get("pages")))
|
|
57
|
+
hidden = (queries.get("anonymized_share") or {}).get("clicks")
|
|
58
|
+
if hidden:
|
|
59
|
+
lines.append(f"{hidden:.0%} of clicks come from searches Google does not name.")
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@gsc.command("data")
|
|
64
|
+
@common_options
|
|
65
|
+
@command_runner(human=_describe_performance)
|
|
66
|
+
@click.option("--days", type=click.IntRange(7, 90), default=28, show_default=True, help="How many days back to read.")
|
|
67
|
+
def data(ctx: click.Context, org_flag: str | None, days: int) -> dict[str, Any]:
|
|
68
|
+
"""The stored search numbers: totals, top queries and top pages."""
|
|
69
|
+
client = open_client(ctx, org_flag)
|
|
70
|
+
try:
|
|
71
|
+
answer: dict[str, Any] = client.call(GSC_PERFORMANCE, params={"window_days": days})
|
|
72
|
+
except CliError as error:
|
|
73
|
+
if error.exit_code == ExitCode.NOT_FOUND:
|
|
74
|
+
raise CliError(ExitCode.NOT_FOUND, "gsc_unavailable", SWITCHED_OFF, 404) from error
|
|
75
|
+
raise
|
|
76
|
+
step = _NEEDS_A_STEP.get((answer.get("source") or {}).get("state") or "")
|
|
77
|
+
if step:
|
|
78
|
+
raise CliError(ExitCode.CONFLICT, *step, 409)
|
|
79
|
+
return answer
|