socialcli 0.3.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.
Potentially problematic release.
This version of socialcli might be problematic. Click here for more details.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- socialctl/youtube_data_service.py +225 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Immutable exact proposals for one Analytics group or Reporting job effect."""
|
|
2
|
+
from contextlib import ExitStack
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from socialctl.management.changes import ApprovalMismatch, now_utc
|
|
13
|
+
from socialctl.management.resource_changes import ResourceStore, fingerprint, _event
|
|
14
|
+
from socialctl.management.youtube_resources import ResourceError, ResourceRejected, ResourceUncertain
|
|
15
|
+
from socialctl.metricas.analytics_client import ANALYTICS, REPORTING, complete, opaque, encoded
|
|
16
|
+
from socialctl.metricas.reporting_client import ANALYTICS_SCOPES
|
|
17
|
+
|
|
18
|
+
FIELDS = {"group-create": {"title", "item_type"}, "group-update": {"group_id", "title"},
|
|
19
|
+
"group-delete": {"group_id"}, "group-item-create": {"group_id", "resource_id"},
|
|
20
|
+
"group-item-delete": {"group_id", "item_id"}, "job-create": {"report_type_id", "name"},
|
|
21
|
+
"job-delete": {"job_id", "report_type_id"}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_edit(raw):
|
|
25
|
+
if not isinstance(raw, dict) or type(raw.get("version")) is not int or raw["version"] != 1 or raw.get("action") not in FIELDS:
|
|
26
|
+
raise ResourceError("unsupported Analytics edit action/version")
|
|
27
|
+
required = FIELDS[raw["action"]] | {"version", "action"}
|
|
28
|
+
if set(raw) != required:
|
|
29
|
+
raise ResourceError("edit requires exact action fields; group update changes only title")
|
|
30
|
+
for field in required - {"version", "action", "title", "name", "item_type"}:
|
|
31
|
+
opaque(raw[field])
|
|
32
|
+
for field in {"title", "name"} & required:
|
|
33
|
+
if not isinstance(raw[field], str) or not raw[field].strip() or len(raw[field]) > 150 or "\x00" in raw[field]:
|
|
34
|
+
raise ResourceError("title/name requires nonempty text, local limit 150")
|
|
35
|
+
if "item_type" in raw and raw["item_type"] not in {"youtube#video", "youtube#channel", "youtube#playlist"}:
|
|
36
|
+
raise ResourceError("content-owner asset groups not enabled")
|
|
37
|
+
return json.loads(json.dumps(raw))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AnalyticsChange(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
version: Literal[1] = 1
|
|
43
|
+
kind: Literal["youtube-analytics"] = "youtube-analytics"
|
|
44
|
+
id: str
|
|
45
|
+
target_brand: str
|
|
46
|
+
brand_root: str
|
|
47
|
+
target_account: str
|
|
48
|
+
edit: dict
|
|
49
|
+
before: dict
|
|
50
|
+
after: dict
|
|
51
|
+
effects: list[str]
|
|
52
|
+
credential_observation: dict
|
|
53
|
+
fingerprint: str = ""
|
|
54
|
+
created_at: datetime
|
|
55
|
+
updated_at: datetime
|
|
56
|
+
status: Literal["proposed", "applying", "uncertain", "failed", "conflict", "verified"] = "proposed"
|
|
57
|
+
verified: bool = False
|
|
58
|
+
result_id: str | None = None
|
|
59
|
+
receipt: dict | None = None
|
|
60
|
+
journal: list[dict] = Field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AnalyticsStore(ResourceStore):
|
|
64
|
+
model_type = AnalyticsChange
|
|
65
|
+
fingerprint_for = staticmethod(fingerprint)
|
|
66
|
+
|
|
67
|
+
def __init__(self, brand_root):
|
|
68
|
+
self.root = Path(brand_root) / ".socialctl" / "youtube-analytics-changes"
|
|
69
|
+
|
|
70
|
+
def load(self, change_id):
|
|
71
|
+
if self.path_for(change_id).is_symlink():
|
|
72
|
+
raise ResourceError("proposal symlink rejected")
|
|
73
|
+
return super().load(change_id)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scopes(client, edit):
|
|
77
|
+
# Analytics group mutations explicitly accept the analytics grants too;
|
|
78
|
+
# do not demand a broader account grant when one is already sufficient.
|
|
79
|
+
client.require_scope(ANALYTICS_SCOPES if edit["action"].startswith("job-") else
|
|
80
|
+
ANALYTICS_SCOPES | {"https://www.googleapis.com/auth/youtube"})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def baseline(client, edit):
|
|
84
|
+
action = edit["action"]
|
|
85
|
+
if action == "group-create":
|
|
86
|
+
rows = complete(client.groups())
|
|
87
|
+
if any(r["snippet"]["title"] == edit["title"] and r["contentDetails"]["itemType"] == edit["item_type"] for r in rows):
|
|
88
|
+
raise ResourceError("matching group already exists; no implicit replacement")
|
|
89
|
+
return {"existing_group_ids": sorted(r["id"] for r in rows)}
|
|
90
|
+
if action == "job-create":
|
|
91
|
+
types = [r for r in complete(client.report_types()) if r["id"] == edit["report_type_id"]]
|
|
92
|
+
if len(types) != 1 or types[0].get("systemManaged", False):
|
|
93
|
+
raise ResourceError("report type unavailable or system managed")
|
|
94
|
+
jobs = complete(client.jobs())
|
|
95
|
+
if any(j["reportTypeId"] == edit["report_type_id"] for j in jobs):
|
|
96
|
+
raise ResourceError("job already exists for report type; no duplicate create")
|
|
97
|
+
return {"report_type": types[0], "existing_job_ids": sorted(j["id"] for j in jobs)}
|
|
98
|
+
if action == "job-delete":
|
|
99
|
+
row = client.job(edit["job_id"], edit["report_type_id"])
|
|
100
|
+
if row.get("systemManaged", False):
|
|
101
|
+
raise ResourceError("system-managed job cannot be deleted")
|
|
102
|
+
return row
|
|
103
|
+
group = client.group(edit["group_id"])
|
|
104
|
+
if action.startswith("group-item"):
|
|
105
|
+
items = complete(client.group_items(edit["group_id"]))
|
|
106
|
+
if action == "group-item-create":
|
|
107
|
+
owned = client.bind_group_resource(group, edit["resource_id"])
|
|
108
|
+
members = [r for r in items if r["resource"]["id"] == edit["resource_id"]]
|
|
109
|
+
if len(members) > 1:
|
|
110
|
+
raise ResourceError("ambiguous group membership")
|
|
111
|
+
return {"group": group, "members": members, "owned_resource_id": owned["id"]}
|
|
112
|
+
matches = [r for r in items if r["id"] == edit["item_id"]]
|
|
113
|
+
if len(matches) != 1:
|
|
114
|
+
raise ResourceError("item ID not verified in exact group")
|
|
115
|
+
return {"group": group, "item": matches[0]}
|
|
116
|
+
return group
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def after_state(edit):
|
|
120
|
+
action = edit["action"]
|
|
121
|
+
if action == "group-create":
|
|
122
|
+
return {"snippet": {"title": edit["title"]}, "contentDetails": {"itemType": edit["item_type"]}}
|
|
123
|
+
if action == "group-update":
|
|
124
|
+
return {"id": edit["group_id"], "snippet": {"title": edit["title"]}}
|
|
125
|
+
if action == "group-item-create":
|
|
126
|
+
return {"groupId": edit["group_id"], "resource": {"id": edit["resource_id"]}}
|
|
127
|
+
if action == "job-create":
|
|
128
|
+
return {"reportTypeId": edit["report_type_id"], "name": edit["name"]}
|
|
129
|
+
return {"deleted_id": edit.get("item_id", edit.get("group_id", edit.get("job_id")))}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def prepare(client, store, raw):
|
|
133
|
+
edit = validate_edit(raw)
|
|
134
|
+
account = client.identity()
|
|
135
|
+
scopes(client, edit)
|
|
136
|
+
before = baseline(client, edit)
|
|
137
|
+
moment = now_utc()
|
|
138
|
+
effects = ["Exactly one remote " + edit["action"],
|
|
139
|
+
"No rollback guarantee; group IDs are independent of playlists; membership does not edit media.",
|
|
140
|
+
"Provider has no documented conditional version guard here; read/preflight cannot eliminate remote race."]
|
|
141
|
+
action_effect = {
|
|
142
|
+
"job-create": "Job creation starts recurring provider report generation; first report may take up to 48h.",
|
|
143
|
+
"job-delete": "Job deletion stops generation; stored report provenance retained, no local purge.",
|
|
144
|
+
"group-delete": "Group deletion removes its grouping/membership; original videos/channels/playlists remain.",
|
|
145
|
+
}.get(edit["action"])
|
|
146
|
+
if action_effect:
|
|
147
|
+
effects.append(action_effect)
|
|
148
|
+
change = AnalyticsChange(id=str(uuid.uuid4()), target_brand=client.brand.nombre,
|
|
149
|
+
brand_root=str(client.brand.raiz.resolve()), target_account=account, edit=edit,
|
|
150
|
+
before=before, after=after_state(edit), credential_observation=client.credential_observation,
|
|
151
|
+
effects=effects,
|
|
152
|
+
created_at=moment, updated_at=moment)
|
|
153
|
+
_event(change, "prepared")
|
|
154
|
+
change.fingerprint = fingerprint(change)
|
|
155
|
+
store.save(change)
|
|
156
|
+
return change
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def identity(client, change):
|
|
160
|
+
if client.brand.nombre != change.target_brand or str(client.brand.raiz.resolve()) != change.brand_root or client.identity() != change.target_account:
|
|
161
|
+
raise ResourceError("proposal brand/account/root mismatch")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def operation_key(change):
|
|
165
|
+
e = change.edit
|
|
166
|
+
if e["action"].startswith("job-"):
|
|
167
|
+
target = ["job", e["report_type_id"]]
|
|
168
|
+
elif e["action"] == "group-create":
|
|
169
|
+
target = ["group-create", e["title"], e["item_type"]]
|
|
170
|
+
elif e["action"].startswith("group-item"):
|
|
171
|
+
target = ["group-item", e["group_id"], e.get("resource_id") or change.before["item"]["resource"]["id"]]
|
|
172
|
+
else:
|
|
173
|
+
target = ["group", e["group_id"]]
|
|
174
|
+
return json.dumps([change.target_account, *target], sort_keys=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def mutate(client, change):
|
|
178
|
+
e, a = change.edit, change.edit["action"]
|
|
179
|
+
if a.startswith("job-"):
|
|
180
|
+
url = REPORTING + "/jobs" + ("/" + encoded(e["job_id"]) if a == "job-delete" else "")
|
|
181
|
+
else:
|
|
182
|
+
url = ANALYTICS + ("/groupItems" if a.startswith("group-item") else "/groups")
|
|
183
|
+
method = "DELETE" if a.endswith("delete") else "PUT" if a == "group-update" else "POST"
|
|
184
|
+
kwargs = ({"params": {"id": change.after["deleted_id"]}} if method == "DELETE" and not a.startswith("job-") else {})
|
|
185
|
+
if method != "DELETE":
|
|
186
|
+
kwargs["json"] = change.after
|
|
187
|
+
response = client.service_request(method, url, **kwargs)
|
|
188
|
+
if method == "DELETE":
|
|
189
|
+
# Reporting documents an empty successful body, unlike Analytics' exact
|
|
190
|
+
# 204 contract. Some Google transports serialize google.protobuf.Empty
|
|
191
|
+
# as {}, also explicitly accepted for Reporting only.
|
|
192
|
+
accepted = response.status_code == 204 and not response.content
|
|
193
|
+
if a == "job-delete" and response.status_code == 200:
|
|
194
|
+
accepted = not response.content or response.content.strip() == b"{}"
|
|
195
|
+
if not accepted:
|
|
196
|
+
raise ResourceUncertain("delete response did not match documented empty success")
|
|
197
|
+
return {"http_status": response.status_code, "deleted_id": change.after["deleted_id"], "empty_success": True}, None
|
|
198
|
+
if a == "group-item-create" and response.status_code == 204:
|
|
199
|
+
return {"http_status": 204, "duplicate_membership": True, "group_id": e["group_id"], "resource_id": e["resource_id"]}, None
|
|
200
|
+
raw = client._json(response, writing=True)
|
|
201
|
+
try:
|
|
202
|
+
rid = opaque(raw.get("id"))
|
|
203
|
+
except ResourceError:
|
|
204
|
+
rid = None
|
|
205
|
+
return {"http_status": response.status_code, "response": raw}, rid
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def readback(client, store, change):
|
|
209
|
+
identity(client, change)
|
|
210
|
+
a, e = change.edit["action"], change.edit
|
|
211
|
+
verified = False
|
|
212
|
+
try:
|
|
213
|
+
if change.receipt:
|
|
214
|
+
response = change.receipt.get("response", {})
|
|
215
|
+
if a == "group-item-create" and change.receipt.get("duplicate_membership"):
|
|
216
|
+
rows = complete(client.group_items(e["group_id"]))
|
|
217
|
+
verified = len([r for r in rows if r["resource"]["id"] == e["resource_id"]]) == 1
|
|
218
|
+
elif a.endswith("delete") and change.receipt.get("empty_success"):
|
|
219
|
+
rows = complete(client.jobs() if a == "job-delete" else client.group_items(e["group_id"]) if a == "group-item-delete" else client.groups())
|
|
220
|
+
verified = all(r["id"] != change.after["deleted_id"] for r in rows)
|
|
221
|
+
elif change.result_id:
|
|
222
|
+
rid = change.result_id
|
|
223
|
+
if a.startswith("group-item"):
|
|
224
|
+
rows = [r for r in complete(client.group_items(e["group_id"])) if r["id"] == rid]
|
|
225
|
+
verified = response.get("groupId") == e["group_id"] and response.get("resource", {}).get("id") == e["resource_id"] and len(rows) == 1 and rows[0]["resource"]["id"] == e["resource_id"]
|
|
226
|
+
elif a.startswith("group-"):
|
|
227
|
+
row = client.group(rid)
|
|
228
|
+
kind = e.get("item_type") or change.before["contentDetails"]["itemType"]
|
|
229
|
+
verified = (a != "group-update" or rid == e["group_id"]) and response.get("snippet", {}).get("title") == e["title"] and row["snippet"]["title"] == e["title"] and row["contentDetails"]["itemType"] == kind
|
|
230
|
+
if a == "group-create":
|
|
231
|
+
verified = verified and response.get("contentDetails", {}).get("itemType") == kind
|
|
232
|
+
elif a == "job-create":
|
|
233
|
+
row = client.job(rid, e["report_type_id"])
|
|
234
|
+
verified = all(response.get(k) == v and row.get(k) == v for k, v in change.after.items())
|
|
235
|
+
_event(change, "readback", matched=verified, limitation="receipt required; missing/partial lists do not prove absence")
|
|
236
|
+
except (ResourceError, TypeError, KeyError, AttributeError):
|
|
237
|
+
_event(change, "readback_unavailable")
|
|
238
|
+
if verified:
|
|
239
|
+
change.status, change.verified = "verified", True
|
|
240
|
+
else:
|
|
241
|
+
change.status = "uncertain"
|
|
242
|
+
store.save(change)
|
|
243
|
+
return change
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def reconcile(client, store, change_id):
|
|
247
|
+
with store.apply_lock(change_id):
|
|
248
|
+
change = store.load(change_id)
|
|
249
|
+
identity(client, change)
|
|
250
|
+
if change.status in {"applying", "uncertain"}:
|
|
251
|
+
return readback(client, store, change)
|
|
252
|
+
return change
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def apply(client, store, change_id, approval):
|
|
256
|
+
with ExitStack() as locks:
|
|
257
|
+
locks.enter_context(store.apply_lock(change_id))
|
|
258
|
+
change = store.load(change_id)
|
|
259
|
+
if not hmac.compare_digest(approval, change.fingerprint):
|
|
260
|
+
raise ApprovalMismatch("approval does not match exact immutable fingerprint")
|
|
261
|
+
locks.enter_context(store.apply_lock(str(uuid.uuid5(uuid.NAMESPACE_URL, change.target_account + ":analytics-effects"))))
|
|
262
|
+
identity(client, change)
|
|
263
|
+
if change.status in {"applying", "uncertain"}:
|
|
264
|
+
return readback(client, store, change)
|
|
265
|
+
if change.status != "proposed":
|
|
266
|
+
return change
|
|
267
|
+
validate_edit(change.edit)
|
|
268
|
+
scopes(client, change.edit)
|
|
269
|
+
for path in store.root.glob("*.json"):
|
|
270
|
+
other = store.load(path.stem)
|
|
271
|
+
if other.id != change.id and other.status in {"applying", "uncertain"} and operation_key(other) == operation_key(change):
|
|
272
|
+
raise ResourceError("uncertain earlier effect blocks a new UUID; reconcile without retry")
|
|
273
|
+
if baseline(client, change.edit) != change.before:
|
|
274
|
+
change.status = "conflict"
|
|
275
|
+
_event(change, "preflight_conflict")
|
|
276
|
+
store.save(change)
|
|
277
|
+
raise ResourceError("remote baseline changed; exact approval no longer applies")
|
|
278
|
+
change.status = "applying"
|
|
279
|
+
_event(change, "write_intent", approval_digest=approval)
|
|
280
|
+
store.save(change)
|
|
281
|
+
try:
|
|
282
|
+
change.receipt, change.result_id = mutate(client, change)
|
|
283
|
+
except ResourceRejected as exc:
|
|
284
|
+
change.status = "failed"
|
|
285
|
+
_event(change, "write_rejected", http_status=exc.http_status,
|
|
286
|
+
provider_reason=exc.provider_reason)
|
|
287
|
+
except Exception:
|
|
288
|
+
change.status = "uncertain"
|
|
289
|
+
_event(change, "write_uncertain")
|
|
290
|
+
else:
|
|
291
|
+
change.status = "uncertain"
|
|
292
|
+
_event(change, "write_receipt", result_id=change.result_id)
|
|
293
|
+
# Receipt is durable before any verification request.
|
|
294
|
+
store.save(change)
|
|
295
|
+
return readback(client, store, change) if change.status == "uncertain" else change
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Named Analytics/Reporting commands; reads never create jobs or refresh OAuth."""
|
|
2
|
+
from datetime import date
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from socialctl.approval_cli import read_approval
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from socialctl.metricas.analytics_changes import AnalyticsStore, prepare as prepare_change, apply as apply_change, reconcile as reconcile_change
|
|
13
|
+
from socialctl.metricas.analytics_ingest import ObservationStore, read_supplied
|
|
14
|
+
from socialctl.metricas.analytics_schema import ReportQuery
|
|
15
|
+
from socialctl.metricas.reporting_client import ReportingClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_http_client():
|
|
19
|
+
return httpx.Client(timeout=60, follow_redirects=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def output(value):
|
|
23
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def preview(change):
|
|
27
|
+
return "DRY-RUN — COMPLETE PREVIEW Analytics/Reporting\n" + output(change.model_dump(mode="json")) + "\nExact fingerprint for approval: " + change.fingerprint
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_mapping(path):
|
|
31
|
+
try:
|
|
32
|
+
value = yaml.safe_load(read_supplied(path, 65536))
|
|
33
|
+
except yaml.YAMLError:
|
|
34
|
+
raise ValueError("invalid supplied JSON/YAML") from None
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
raise ValueError("supplied JSON/YAML must be a mapping")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register(content_app, default_root):
|
|
41
|
+
from socialctl.management.cli import _brand, _fail
|
|
42
|
+
analytics = typer.Typer(help="Analytics/Reporting: official periods, CSV and exact proposals.")
|
|
43
|
+
content_app.add_typer(analytics, name="youtube-analytics")
|
|
44
|
+
|
|
45
|
+
def read(root, brand, callback):
|
|
46
|
+
selected = _brand(root, brand)
|
|
47
|
+
try:
|
|
48
|
+
with make_http_client() as http:
|
|
49
|
+
value = callback(ReportingClient(selected, http))
|
|
50
|
+
except (ValueError, OSError, KeyError, TypeError) as exc:
|
|
51
|
+
_fail(str(exc))
|
|
52
|
+
typer.echo(output(value))
|
|
53
|
+
if isinstance(value, dict) and value.get("complete") is False:
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
@analytics.command("query")
|
|
57
|
+
def query(file: Path = typer.Option(..., "--file"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
58
|
+
def run(client):
|
|
59
|
+
result = client.query(ReportQuery.model_validate(load_mapping(file)))
|
|
60
|
+
saved = ObservationStore(client.brand.raiz, client.configured_channel_id).save_query(result)
|
|
61
|
+
return {**result, "stored": saved}
|
|
62
|
+
read(root, brand, run)
|
|
63
|
+
|
|
64
|
+
@analytics.command("compare")
|
|
65
|
+
def compare(end: str = typer.Option(..., "--end"), metric: list[str] = typer.Option(None, "--metric"), video_id: list[str] = typer.Option(None, "--video-id"),
|
|
66
|
+
brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
67
|
+
def run(client):
|
|
68
|
+
result = client.compare(date.fromisoformat(end), metric, video_id)
|
|
69
|
+
store = ObservationStore(client.brand.raiz, client.configured_channel_id)
|
|
70
|
+
for window in result["windows"]:
|
|
71
|
+
for name in ("current", "previous"):
|
|
72
|
+
window[name]["stored"] = store.save_query(window[name])
|
|
73
|
+
result["complete"] = all(w[k]["complete"] for w in result["windows"] for k in ("current", "previous"))
|
|
74
|
+
return result
|
|
75
|
+
read(root, brand, run)
|
|
76
|
+
|
|
77
|
+
@analytics.command("batch-stats")
|
|
78
|
+
def batch_stats(video_id: list[str] = typer.Option(..., "--video-id"), part: list[str] = typer.Option(None, "--part"),
|
|
79
|
+
brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
80
|
+
read(root, brand, lambda c: c.batch_stats(video_id, part))
|
|
81
|
+
|
|
82
|
+
@analytics.command("groups-list")
|
|
83
|
+
def groups_list(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"), max_pages: int = typer.Option(100, "--max-pages", min=1, max=100)):
|
|
84
|
+
read(root, brand, lambda c: c.groups(max_pages))
|
|
85
|
+
|
|
86
|
+
@analytics.command("group-show")
|
|
87
|
+
def group_show(group_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
88
|
+
read(root, brand, lambda c: c.group(group_id))
|
|
89
|
+
|
|
90
|
+
@analytics.command("group-items-list")
|
|
91
|
+
def group_items_list(group_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
92
|
+
read(root, brand, lambda c: c.group_items(group_id))
|
|
93
|
+
|
|
94
|
+
@analytics.command("report-types-list")
|
|
95
|
+
def report_types_list(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"), max_pages: int = typer.Option(100, "--max-pages", min=1, max=100)):
|
|
96
|
+
read(root, brand, lambda c: c.report_types(max_pages))
|
|
97
|
+
|
|
98
|
+
@analytics.command("jobs-list")
|
|
99
|
+
def jobs_list(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"), max_pages: int = typer.Option(100, "--max-pages", min=1, max=100)):
|
|
100
|
+
read(root, brand, lambda c: c.jobs(max_pages))
|
|
101
|
+
|
|
102
|
+
@analytics.command("job-show")
|
|
103
|
+
def job_show(job_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
104
|
+
read(root, brand, lambda c: c.job(job_id, report_type_id))
|
|
105
|
+
|
|
106
|
+
@analytics.command("reports-list")
|
|
107
|
+
def reports_list(job_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"),
|
|
108
|
+
max_pages: int = typer.Option(100, "--max-pages", min=1, max=100), created_after: str | None = typer.Option(None, "--created-after"),
|
|
109
|
+
start_time_at_or_after: str | None = typer.Option(None, "--start-time-at-or-after"), start_time_before: str | None = typer.Option(None, "--start-time-before")):
|
|
110
|
+
read(root, brand, lambda c: c.reports(job_id, report_type_id, max_pages, created_after, start_time_at_or_after, start_time_before))
|
|
111
|
+
|
|
112
|
+
@analytics.command("report-show")
|
|
113
|
+
def report_show(job_id: str, report_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
114
|
+
read(root, brand, lambda c: c.report(job_id, report_type_id, report_id))
|
|
115
|
+
|
|
116
|
+
@analytics.command("report-download")
|
|
117
|
+
def report_download(job_id: str, report_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), start: str = typer.Option(..., "--start"), end: str = typer.Option(..., "--end"),
|
|
118
|
+
brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
119
|
+
"""Download and atomically import CSV and provenance into private storage."""
|
|
120
|
+
def run(client):
|
|
121
|
+
binding, data = client.download(job_id=job_id, report_id=report_id, report_type_id=report_type_id, start_date=start, end_date=end)
|
|
122
|
+
store = ObservationStore(client.brand.raiz, client.configured_channel_id)
|
|
123
|
+
value = store.import_csv(data, binding)
|
|
124
|
+
return {"id": value["id"], "rows": len(value["rows"]), "binding": binding, "raw_path": str(store.root / (value["id"] + ".csv")), "checkpoint": "complete"}
|
|
125
|
+
read(root, brand, run)
|
|
126
|
+
|
|
127
|
+
@analytics.command("studio-import")
|
|
128
|
+
def studio_import(file: Path = typer.Option(..., "--file"), metadata: Path = typer.Option(..., "--metadata"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
129
|
+
selected = _brand(root, brand)
|
|
130
|
+
try:
|
|
131
|
+
binding = load_mapping(metadata)
|
|
132
|
+
if binding.get("source") != "youtubeStudio":
|
|
133
|
+
raise ValueError("studio-import requires source youtubeStudio; supplied files cannot assert API authentication")
|
|
134
|
+
account = selected.cuentas.get("youtube", {}).get("channel_id")
|
|
135
|
+
store = ObservationStore(selected.raiz, account)
|
|
136
|
+
value = store.import_csv(read_supplied(file), binding)
|
|
137
|
+
except (ValueError, OSError, KeyError, TypeError) as exc:
|
|
138
|
+
_fail(str(exc))
|
|
139
|
+
typer.echo(output({"id": value["id"], "rows": len(value["rows"]), "provenance": value["provenance"]}))
|
|
140
|
+
|
|
141
|
+
@analytics.command("observations")
|
|
142
|
+
def observations(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
143
|
+
selected = _brand(root, brand)
|
|
144
|
+
try:
|
|
145
|
+
value = ObservationStore(selected.raiz, selected.cuentas.get("youtube", {}).get("channel_id")).active()
|
|
146
|
+
except (ValueError, OSError) as exc:
|
|
147
|
+
_fail(str(exc))
|
|
148
|
+
typer.echo(output(value))
|
|
149
|
+
|
|
150
|
+
@analytics.command("prepare")
|
|
151
|
+
def prepare(file: Path = typer.Option(..., "--file"), dry_run: bool = typer.Option(False, "--dry-run"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
152
|
+
selected = _brand(root, brand)
|
|
153
|
+
try:
|
|
154
|
+
with make_http_client() as http:
|
|
155
|
+
change = prepare_change(ReportingClient(selected, http), AnalyticsStore(selected.raiz), load_mapping(file))
|
|
156
|
+
except (ValueError, OSError) as exc:
|
|
157
|
+
_fail(str(exc))
|
|
158
|
+
typer.echo(preview(change))
|
|
159
|
+
|
|
160
|
+
@analytics.command("apply")
|
|
161
|
+
def apply(change_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"),
|
|
162
|
+
approval_fingerprint: str | None = typer.Option(None, "--approval-fingerprint", "--approval", "--approval-digest", help="Exact fingerprint from the approved complete preview."),
|
|
163
|
+
approval_stdin: bool = typer.Option(False, "--approval-stdin", help="Explicitly read one approval line from stdin.")):
|
|
164
|
+
selected = _brand(root, brand)
|
|
165
|
+
store = AnalyticsStore(selected.raiz)
|
|
166
|
+
try:
|
|
167
|
+
change = store.load(change_id)
|
|
168
|
+
typer.echo(preview(change))
|
|
169
|
+
approval = read_approval(approval_fingerprint, approval_stdin=approval_stdin)
|
|
170
|
+
if approval != change.fingerprint:
|
|
171
|
+
raise ValueError("approval does not match fingerprint")
|
|
172
|
+
with make_http_client() as http:
|
|
173
|
+
result = apply_change(ReportingClient(selected, http), store, change_id, approval)
|
|
174
|
+
except (ValueError, OSError) as exc:
|
|
175
|
+
_fail(str(exc))
|
|
176
|
+
typer.echo(output(result.model_dump(mode="json")))
|
|
177
|
+
if not result.verified:
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
@analytics.command("status")
|
|
181
|
+
def status(change_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
182
|
+
selected = _brand(root, brand)
|
|
183
|
+
try:
|
|
184
|
+
value = AnalyticsStore(selected.raiz).load(change_id)
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
_fail(str(exc))
|
|
187
|
+
typer.echo(output(value.model_dump(mode="json")))
|
|
188
|
+
|
|
189
|
+
@analytics.command("reconcile")
|
|
190
|
+
def reconcile(change_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
|
|
191
|
+
read(root, brand, lambda c: reconcile_change(c, AnalyticsStore(c.brand.raiz), change_id).model_dump(mode="json"))
|