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.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,304 @@
1
+ """Bounded account reads and fixed Analytics transport; no credential refresh."""
2
+ from datetime import timedelta
3
+ import json
4
+ import re
5
+ import time
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+ from socialctl.identity import credential_metadata
11
+ from socialctl.management.youtube_owned import YouTubeOwnedClient
12
+ from socialctl.management.youtube_resources import API, ResourceError, ResourceRejected, ResourceUncertain, provider_reason, resource_id
13
+ from socialctl.metricas.analytics_schema import ReportQuery, parse_query_response, provenance, metric_value
14
+ from socialctl.models import Platform
15
+
16
+ ANALYTICS = "https://youtubeanalytics.googleapis.com/v2"
17
+ REPORTING = "https://youtubereporting.googleapis.com/v1"
18
+
19
+
20
+ def opaque(value):
21
+ if not isinstance(value, str) or not 1 <= len(value) <= 512 or any(ord(c) < 33 or ord(c) == 127 for c in value):
22
+ raise ResourceError("invalid opaque provider ID")
23
+ return value
24
+
25
+
26
+ def encoded(value):
27
+ return quote(opaque(value), safe="")
28
+
29
+
30
+ def complete(result):
31
+ if not result["complete"]:
32
+ raise ResourceError("incomplete provider list; absence/ownership not established")
33
+ return result["items"]
34
+
35
+
36
+ def unsigned_long(value):
37
+ """Exact documented unsigned long; preserve caller's raw wire value."""
38
+ if type(value) is int:
39
+ number = value
40
+ elif isinstance(value, str) and re.fullmatch(r"[0-9]{1,20}", value):
41
+ number = int(value)
42
+ else:
43
+ raise ResourceError("unsigned long schema invalid")
44
+ if not 0 <= number <= 18446744073709551615:
45
+ raise ResourceError("unsigned long outside uint64 range")
46
+ return number
47
+
48
+
49
+ def failure(exc):
50
+ if exc.http_status == 400:
51
+ return "provider_rejected_report_combination"
52
+ if exc.http_status == 429 or exc.provider_reason == "quotaExceeded":
53
+ return "rate_limited"
54
+ if exc.http_status in {401, 403}:
55
+ return "forbidden_or_missing_grant"
56
+ return "provider_or_schema_unavailable"
57
+
58
+
59
+ class AnalyticsClient(YouTubeOwnedClient):
60
+ def _token(self):
61
+ if self._access_token is None:
62
+ try:
63
+ secret = self.brand.leer_secreto(Platform.YOUTUBE)
64
+ self.credential_observation = credential_metadata(secret)
65
+ if secret.get("auth_mode") == "broker":
66
+ from socialctl.connections.client import broker_access_token
67
+ token = broker_access_token(self.brand, Platform.YOUTUBE, self.client, refresh=False)
68
+ else:
69
+ token = secret.get("access_token")
70
+ except Exception:
71
+ raise ResourceError("credentials unavailable") from None
72
+ if self.credential_observation["expiry"]["expired"]:
73
+ raise ResourceError("credentials expired; no implicit refresh")
74
+ if not isinstance(token, str) or not token:
75
+ raise ResourceError("credentials unavailable")
76
+ self._access_token = token
77
+ return self._access_token
78
+
79
+ def require_scope(self, scopes):
80
+ self._token()
81
+ actual = self.credential_observation["granted_scopes"]
82
+ if actual["state"] != "known" or not set(actual["values"]) & set(scopes):
83
+ raise ResourceError("required granted scope not recorded; no new grant or refresh attempted")
84
+
85
+ def _endpoint_allowed(self, method, path):
86
+ return method == "GET" and path in {"/youtube/v3/channels", "/youtube/v3/videos", "/youtube/v3/playlists", "/youtube/v3/videos:batchGetStats"}
87
+
88
+ def service_request(self, method, url, *, max_bytes=4*1024*1024, **kwargs):
89
+ parsed = httpx.URL(url)
90
+ paths = {("GET", "/v2/reports"), *( (m, "/v2/" + r) for r in ("groups", "groupItems") for m in ("GET", "POST", "PUT", "DELETE") if not (r == "groupItems" and m == "PUT") )}
91
+ reporting = ((method, parsed.path) in {("GET", "/v1/reportTypes"), ("GET", "/v1/jobs"), ("POST", "/v1/jobs")}
92
+ or method in {"GET", "DELETE"} and bool(re.fullmatch(r"/v1/jobs/[^/]+", parsed.path))
93
+ or method == "GET" and bool(re.fullmatch(r"/v1/jobs/[^/]+/reports(?:/[^/]+)?", parsed.path)))
94
+ allowed = (parsed.host == "youtubeanalytics.googleapis.com" and (method, parsed.path) in paths
95
+ or parsed.host == "youtubereporting.googleapis.com" and reporting
96
+ or parsed.host == "www.googleapis.com" and method == "GET" and parsed.path == "/youtube/v3/videos:batchGetStats")
97
+ if not allowed or parsed.scheme != "https" or parsed.port not in {None, 443} or parsed.userinfo or parsed.query or parsed.fragment:
98
+ raise ResourceError("endpoint not allowed")
99
+ return self._bounded_request(method, url, max_bytes=max_bytes, **kwargs)
100
+
101
+ def _bounded_request(self, method, url, *, max_bytes, **kwargs):
102
+ token = self._token()
103
+ writing = method != "GET"
104
+ deadline = time.monotonic() + 120
105
+ try:
106
+ with self.client.stream(method, url, headers={"Authorization": f"Bearer {token}", "Accept-Encoding": "identity"},
107
+ follow_redirects=False, timeout=60, **kwargs) as response:
108
+ if not response.is_success:
109
+ error = (ResourceUncertain if response.is_redirect or response.status_code >= 500 else ResourceRejected) if writing else ResourceError
110
+ reason = provider_reason(response, token)
111
+ raise error(f"provider HTTP {response.status_code}; effect unconfirmed", http_status=response.status_code, provider_reason=reason)
112
+ if response.headers.get("content-encoding", "identity") != "identity":
113
+ raise (ResourceUncertain if writing else ResourceError)("compressed response not accepted")
114
+ if not writing and response.status_code != 200:
115
+ raise ResourceError("partial/nonstandard read response not accepted")
116
+ length = response.headers.get("content-length")
117
+ if length is not None and (not re.fullmatch(r"[0-9]{1,12}", length) or int(length) > max_bytes):
118
+ raise (ResourceUncertain if writing else ResourceError)("invalid response length or byte limit exceeded")
119
+ raw = bytearray()
120
+ for chunk in response.iter_bytes(chunk_size=65536):
121
+ if len(raw)+len(chunk) > max_bytes or time.monotonic() > deadline:
122
+ raise (ResourceUncertain if writing else ResourceError)("response byte limit")
123
+ raw.extend(chunk)
124
+ if length is not None and len(raw) != int(length):
125
+ raise (ResourceUncertain if writing else ResourceError)("partial response length mismatch")
126
+ return httpx.Response(response.status_code, content=bytes(raw), request=response.request)
127
+ except ResourceError:
128
+ raise
129
+ except Exception:
130
+ raise (ResourceUncertain if writing else ResourceError)("provider transport interrupted") from None
131
+
132
+ def pages(self, url, key="items", params=None, max_pages=100, paginated=True):
133
+ if type(max_pages) is not int or not 1 <= max_pages <= 100:
134
+ raise ResourceError("page bound 1..100")
135
+ items, seen, ids = [], set(), set()
136
+ params = dict(params or {})
137
+ result = {"items": items, "complete": False, "failure_reason": None, "pages": 0}
138
+ for _ in range(max_pages):
139
+ try:
140
+ payload = self._json(self.service_request("GET", url, params=params))
141
+ if key not in payload and (key == "items" or set(payload)-{"nextPageToken"}):
142
+ raise ResourceError("list envelope schema missing collection")
143
+ rows = payload.get(key, [])
144
+ if not isinstance(rows, list) or len(rows) > 1000 or len(items)+len(rows) > 100000:
145
+ raise ResourceError("invalid/oversized provider page")
146
+ page_ids = [opaque(r.get("id")) if isinstance(r, dict) else opaque(None) for r in rows]
147
+ if len(page_ids) != len(set(page_ids)) or set(page_ids) & ids:
148
+ raise ResourceError("duplicate identity across provider pages")
149
+ token = payload.get("nextPageToken")
150
+ if "nextPageToken" in payload and (not paginated or not isinstance(token, str) or not token or len(token) > 4096 or token in seen):
151
+ raise ResourceError("invalid/repeated pagination token")
152
+ items.extend(rows)
153
+ ids.update(page_ids)
154
+ result["pages"] += 1
155
+ if token is None:
156
+ result["complete"] = True
157
+ return result
158
+ seen.add(token)
159
+ params["pageToken"] = token
160
+ except ResourceError as exc:
161
+ result["failure_reason"] = failure(exc)
162
+ return result
163
+ result["failure_reason"] = "page_limit"
164
+ return result
165
+
166
+ def groups(self, max_pages=100):
167
+ self.identity()
168
+ result = self.pages(f"{ANALYTICS}/groups", params={"mine": "true"}, max_pages=max_pages)
169
+ for row in result["items"]:
170
+ if not isinstance(row.get("snippet"), dict) or not isinstance(row["snippet"].get("title"), str) or not isinstance(row.get("contentDetails"), dict) or row["contentDetails"].get("itemType") not in {"youtube#video", "youtube#channel", "youtube#playlist", "youtubePartner#asset"}:
171
+ raise ResourceError("group schema invalid")
172
+ return result
173
+
174
+ def group(self, group_id):
175
+ rows = [r for r in complete(self.groups()) if r["id"] == opaque(group_id)]
176
+ if len(rows) != 1:
177
+ raise ResourceError("group not verified in authenticated mine membership")
178
+ return rows[0]
179
+
180
+ def group_items(self, group_id):
181
+ self.group(group_id)
182
+ result = self.pages(f"{ANALYTICS}/groupItems", params={"groupId": opaque(group_id)}, paginated=False)
183
+ for row in result["items"]:
184
+ if row.get("groupId") != group_id or not isinstance(row.get("resource"), dict):
185
+ raise ResourceError("groupItems identity mismatch")
186
+ opaque(row["resource"].get("id"))
187
+ return result
188
+
189
+ def bind_group_resource(self, group, rid):
190
+ kind = group["contentDetails"]["itemType"]
191
+ if kind == "youtube#video":
192
+ return self.one("videos", resource_id(rid), parts="snippet")
193
+ if kind == "youtube#channel" and rid == self.identity():
194
+ return {"id": rid}
195
+ if kind == "youtube#playlist":
196
+ return self.one("playlists", resource_id(rid))
197
+ raise ResourceError("resource not owned or content-owner asset unsupported")
198
+
199
+ def query(self, query):
200
+ query = ReportQuery.model_validate(query.model_dump())
201
+ account = self.identity()
202
+ for vid in query.video_ids:
203
+ self.one("videos", vid, parts="snippet")
204
+ for playlist in query.playlist_ids:
205
+ self.one("playlists", playlist)
206
+ if query.group_id:
207
+ self.group(query.group_id)
208
+ result = {"query": query.model_dump(mode="json"), "rows": [], "complete": False, "failure_reason": None,
209
+ "provenance": provenance("youtubeAnalytics.reports.query", account), "pages": 0}
210
+ result["provenance"]["dimensions"] = query.dimensions
211
+ result["provenance"]["paid_organic_eligibility"] = query.audience_type or "report_metric_dependent_not_inferred"
212
+ result["provenance"]["credential_observation"] = self.credential_observation
213
+ params, seen = query.params(), set()
214
+ for _ in range(query.max_pages):
215
+ try:
216
+ payload = self._json(self.service_request("GET", f"{ANALYTICS}/reports", params=params))
217
+ rows = parse_query_response(query, payload)
218
+ keys = [json.dumps(r["dimensions"], sort_keys=True) for r in rows]
219
+ if len(keys) != len(set(keys)) or set(keys) & seen:
220
+ raise ResourceError("duplicate dimensions across pages")
221
+ result["rows"].extend(rows)
222
+ seen.update(keys)
223
+ result["pages"] += 1
224
+ if len(rows) < query.max_results:
225
+ result["complete"] = True
226
+ break
227
+ params["startIndex"] += len(rows)
228
+ except ResourceError as exc:
229
+ result["failure_reason"] = failure(exc)
230
+ break
231
+ if not result["complete"] and result["failure_reason"] is None:
232
+ result["failure_reason"] = "page_limit"
233
+ if not result["rows"]:
234
+ result["missing_metrics"] = {m: {"value": None, "missing_reason": result["failure_reason"] or "no_rows_or_suppressed"} for m in query.metrics}
235
+ days = [r["dimensions"]["day"] for r in result["rows"] if "day" in r["dimensions"]]
236
+ result["provenance"]["returned_date_coverage"] = {"first": min(days) if days else None, "last": max(days) if days else None,
237
+ "observed_days": len(set(days)), "limitation": "pagination complete does not prove all requested dates available"}
238
+ result["provenance"]["metric_definitions"] = {m: {"url": "https://developers.google.com/youtube/analytics/metrics#" + m,
239
+ "aggregation": "official returned value; no local aggregation", "unit": {"estimatedMinutesWatched": "minutes", "estimatedRedMinutesWatched": "minutes",
240
+ "averageViewDuration": "seconds", "averageViewPercentage": "percentage", "viewerPercentage": "percentage"}.get(m, "see exact provider metric definition")} for m in query.metrics}
241
+ return result
242
+
243
+ def compare(self, end_date, metrics=None, video_ids=None):
244
+ result = {"source": "official_period_values_side_by_side", "windows": [], "causal_claim": False,
245
+ "confounders": ["publication cadence", "audience mix", "paid traffic", "seasonality", "provider latency/suppression", "definition changes"],
246
+ "aggregation": "none; no summed rates or unweighted means"}
247
+ metrics = list(dict.fromkeys([*(metrics or ["views", "averageViewDuration"]), "views"]))
248
+ for days in (7, 28):
249
+ current = self.query(ReportQuery(start_date=end_date-timedelta(days=days-1), end_date=end_date, metrics=metrics, video_ids=video_ids or []))
250
+ prior = self.query(ReportQuery(start_date=end_date-timedelta(days=2*days-1), end_date=end_date-timedelta(days=days), metrics=metrics, video_ids=video_ids or []))
251
+ result["windows"].append({"days": days, "current": current, "previous": prior,
252
+ "sample_size": {"unit": "provider views, not unique viewers", "current": self._sample(current), "previous": self._sample(prior)}})
253
+ return result
254
+
255
+ @staticmethod
256
+ def _sample(result):
257
+ return result["rows"][0]["metrics"].get("views") if result["complete"] and len(result["rows"]) == 1 else {"value": None, "missing_reason": "incomplete_or_unavailable"}
258
+
259
+ def batch_stats(self, video_ids, parts=None):
260
+ parts = parts or ["id", "statistics"]
261
+ if not isinstance(video_ids, list) or not 1 <= len(video_ids) <= 50 or len(set(video_ids)) != len(video_ids) or not isinstance(parts, list) or not parts or set(parts)-{"id", "snippet", "statistics", "contentDetails"}:
262
+ raise ResourceError("batch requires 1..50 distinct owned IDs and documented parts (local bound)")
263
+ account = self.identity()
264
+ for vid in video_ids:
265
+ self.one("videos", resource_id(vid), parts="snippet")
266
+ payload = self._json(self.service_request("GET", f"{API}/videos:batchGetStats", params={"id": ",".join(video_ids), "part": ",".join(parts)}))
267
+ rows, summary = payload.get("items"), payload.get("summary")
268
+ has_summary = "summary" in payload
269
+ if not isinstance(rows, list) or has_summary and not isinstance(summary, dict):
270
+ raise ResourceError("batch stats invalid envelope")
271
+ ids = [r.get("id") if isinstance(r, dict) else None for r in rows]
272
+ if any(not isinstance(v, str) for v in ids) or len(set(ids)) != len(ids) or set(ids)-set(video_ids):
273
+ raise ResourceError("batch stats returned duplicate, missing or unexpected identity")
274
+ # Actual responses can omit the documented summary entirely. Returned
275
+ # exact IDs still establish their own values; absence of another ID does
276
+ # not establish provider failure. Explicit null/malformed summaries fail.
277
+ failed = summary.get("failedVideoIds", []) if has_summary else []
278
+ if has_summary and (not isinstance(failed, list) or any(not isinstance(v, str) for v in failed)
279
+ or len(set(ids+failed)) != len(ids+failed) or set(ids+failed) != set(video_ids)
280
+ or any(unsigned_long(summary.get(k)) != n for k, n in {"requestedVideoCount": len(video_ids), "succeededVideoCount": len(rows), "failedVideoCount": len(failed)}.items())):
281
+ raise ResourceError("batch stats exact success/failure IDs or counts mismatch")
282
+ unaccounted = [v for v in video_ids if v not in set(ids+failed)]
283
+ p = provenance("youtube.videos.batchGetStats", account)
284
+ p.update({"credential_observation": self.credential_observation, "provider_summary_present": has_summary,
285
+ "partition_provenance": "returned ID membership checked locally; missing IDs without summary have unknown provider outcomes",
286
+ "period": "cumulative_snapshot_not_Analytics", "definitions_url": "https://developers.google.com/youtube/v3/docs/videos#statistics.viewCount",
287
+ "viewCount_definition_effective": "2026-08-24", "viewCount_definition": "playback-start counting across formats per resource docs; do not apply to Analytics"})
288
+ outcomes = {v: {"value": None, "missing_reason": "provider_failed_id"} for v in failed}
289
+ outcomes.update({v: {"value": None, "missing_reason": "provider_outcome_unaccounted"} for v in unaccounted})
290
+ for row in rows:
291
+ item = {"value": row, "missing_reason": None}
292
+ if "statistics" in parts:
293
+ stats = row.get("statistics", {})
294
+ if not isinstance(stats, dict):
295
+ raise ResourceError("batch statistics schema invalid")
296
+ item["statistics"] = {}
297
+ for name in ("viewCount", "likeCount", "commentCount"):
298
+ value = stats.get(name)
299
+ item["statistics"][name] = metric_value(unsigned_long(value) if value is not None else None, "provider_field_absent")
300
+ outcomes[row["id"]] = item
301
+ return {"outcomes": outcomes, "summary": summary, "complete": not unaccounted,
302
+ "provider_summary": {"value": summary, "missing_reason": None if has_summary else "provider_summary_omitted"},
303
+ "observed_partition": {"source": "local_exact_id_validation", "returned_ids": ids,
304
+ "provider_failed_ids": failed if has_summary else None, "unaccounted_ids": unaccounted}, "provenance": p}
@@ -0,0 +1,260 @@
1
+ """Bounded lossless CSV parsing and crash-safe version/active-period checkpoints."""
2
+ import csv
3
+ from datetime import date
4
+ import io
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import stat
9
+ import tempfile
10
+ import uuid
11
+ from typing import Literal
12
+
13
+ from pydantic import AwareDatetime, BaseModel, ConfigDict
14
+
15
+ from socialctl.identity import credential_metadata
16
+ from socialctl.management.resource_changes import ResourceStore, digest, save_download
17
+ from socialctl.management.youtube_resources import ResourceError
18
+ from socialctl.metricas.analytics_client import opaque
19
+ from socialctl.metricas.analytics_schema import metric_value, provenance
20
+ from socialctl.metricas.reporting_client import MAX_CSV, instant, report_period
21
+
22
+ DIMENSIONS = set("date channel_id video_id playlist_id country_code province_code playback_location_type playback_location_detail live_or_on_demand subscribed_status traffic_source_type traffic_source_detail device_type operating_system age_group gender sharing_service annotation_type annotation_id card_type card_id end_screen_element_type end_screen_element_id subtitle_language subtitle_language_autotranslated".split())
23
+ COUNTS = set("views engaged_views comments likes dislikes shares subscribers_gained subscribers_lost videos_added_to_playlists videos_removed_from_playlists card_clicks card_impressions card_teaser_clicks card_teaser_impressions annotation_clicks annotation_impressions annotation_closable_impressions annotation_clickable_impressions annotation_closes end_screen_element_clicks end_screen_element_impressions".split())
24
+ COUNTS |= {"red_views", "video_thumbnail_impressions"}
25
+ FLOATS = {"watch_time_minutes": "minutes", "average_view_duration_seconds": "seconds", "average_view_duration_percentage": "percentage",
26
+ "annotation_click_through_rate": "rate", "annotation_close_rate": "rate", "card_click_rate": "rate", "card_teaser_click_rate": "rate",
27
+ "end_screen_element_click_rate": "rate", "views_percentage": "percentage", "red_watch_time_minutes": "minutes", "video_thumbnail_impressions_ctr": "percentage"}
28
+ DEFINITIONS = "https://developers.google.com/youtube/reporting/v1/reports/"
29
+
30
+
31
+ class StudioMetadata(BaseModel):
32
+ """Normalize JSON strings and safe_load's native YAML date/time values."""
33
+ model_config = ConfigDict(extra="forbid")
34
+ source: Literal["youtubeStudio"]
35
+ account_id: str
36
+ export_id: str
37
+ start_date: date
38
+ end_date: date
39
+ exported_at: AwareDatetime
40
+ source_filename: str
41
+
42
+
43
+ def read_supplied(path, maximum=MAX_CSV):
44
+ try:
45
+ fd = os.open(Path(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
46
+ with os.fdopen(fd, "rb") as stream:
47
+ before = os.fstat(stream.fileno())
48
+ if not stat.S_ISREG(before.st_mode) or not 0 < before.st_size <= maximum:
49
+ raise ResourceError("supplied file must be bounded nonempty regular file")
50
+ data = stream.read(maximum+1)
51
+ after = os.fstat(stream.fileno())
52
+ if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or len(data) != before.st_size:
53
+ raise ResourceError("supplied file changed during read")
54
+ return data
55
+ except OSError:
56
+ raise ResourceError("cannot read supplied file without following symlinks") from None
57
+
58
+
59
+ def parse_csv(data, binding):
60
+ if not isinstance(data, bytes) or not 0 < len(data) <= MAX_CSV:
61
+ raise ResourceError("CSV byte limit")
62
+ try:
63
+ text = data.decode("utf-8-sig")
64
+ if "\x00" in text:
65
+ raise ValueError()
66
+ reader = csv.reader(io.StringIO(text, newline=""), strict=True)
67
+ headers = next(reader)
68
+ if not headers or len(headers) > 256 or len(set(headers)) != len(headers) or any(not h or len(h) > 256 for h in headers):
69
+ raise ValueError()
70
+ reporting = binding["source"] == "youtubeReporting"
71
+ if reporting:
72
+ start, end = report_period(binding["report"])
73
+ if "date" not in headers:
74
+ raise ResourceError("schema: report has no date; raw download may be saved separately")
75
+ else:
76
+ start, end = date.fromisoformat(binding["start_date"]), date.fromisoformat(binding["end_date"])
77
+ rows, seen = [], set()
78
+ known = DIMENSIONS | COUNTS | set(FLOATS) if reporting else set()
79
+ columns = {h: {"kind": "dimension" if h in DIMENSIONS and reporting else "metric" if h in COUNTS|set(FLOATS) and reporting else "unknown",
80
+ "semantics_source": "official_catalog_2026-09-13" if h in known else "unknown",
81
+ "definition_url": DEFINITIONS + ("dimensions#" if h in DIMENSIONS else "metrics#") + h if h in known else None,
82
+ "unit": "count" if h in COUNTS and reporting else FLOATS.get(h) if reporting else None} for h in headers}
83
+ for raw in reader:
84
+ if len(rows) >= 100000 or len(raw) != len(headers) or any(len(v) > 8192 for v in raw):
85
+ raise ResourceError("CSV row/column/cell limit or schema mismatch")
86
+ values = dict(zip(headers, raw))
87
+ # Only vetted Reporting dimensions establish a row's primary key.
88
+ # Unknown semantics (especially Studio titles) cannot justify dedupe;
89
+ # preserve every such row in source order, including identical rows.
90
+ if reporting and set(headers) <= known:
91
+ identity = tuple(values[h] for h in headers if h in DIMENSIONS)
92
+ if identity in seen:
93
+ raise ResourceError("duplicate CSV dimension identity")
94
+ seen.add(identity)
95
+ if reporting:
96
+ raw_date = values["date"]
97
+ if len(raw_date) != 8 or not raw_date.isdigit():
98
+ raise ResourceError("schema: Reporting date requires YYYYMMDD")
99
+ day = date.fromisoformat(raw_date[:4]+"-"+raw_date[4:6]+"-"+raw_date[6:])
100
+ if not start <= day <= end:
101
+ raise ResourceError("CSV date outside bound report")
102
+ if values.get("channel_id") not in {None, "", binding["account_id"]}:
103
+ raise ResourceError("CSV account mismatch")
104
+ row = {"raw": values, "dimensions": {}, "metrics": {}, "unknown": {}}
105
+ for h, value in values.items():
106
+ if columns[h]["kind"] == "dimension":
107
+ row["dimensions"][h] = value
108
+ elif columns[h]["kind"] == "metric":
109
+ number = None if value == "" else int(value) if h in COUNTS else float(value)
110
+ row["metrics"][h] = metric_value(number, "empty_csv_cell_not_zero")
111
+ else:
112
+ row["unknown"][h] = {"raw": value, "missing_reason": "unknown_column_semantics"}
113
+ rows.append(row)
114
+ return {"headers": headers, "columns": columns, "rows": rows}
115
+ except (ValueError, UnicodeError, csv.Error, StopIteration):
116
+ raise ResourceError("CSV schema/encoding/numeric value invalid; no checkpoint committed") from None
117
+
118
+
119
+ class ObservationStore(ResourceStore):
120
+ def __init__(self, brand_root, account_id):
121
+ self.root = Path(brand_root) / ".socialctl" / "youtube-observations"
122
+ self.account_id = opaque(account_id)
123
+
124
+ def _read_json(self, path):
125
+ value=json.loads(read_supplied(path, 128*1024*1024))
126
+ provenance=value.get('provenance',{})
127
+ if provenance and provenance.get('source')!='youtubeStudio':
128
+ from socialctl.youtube_data import require_fresh_api_data
129
+ require_fresh_api_data(provenance.get('observed_at'))
130
+ return value
131
+
132
+ def index(self):
133
+ self._ensure_root()
134
+ path = self.root / "index.json"
135
+ if not path.exists() and not path.is_symlink():
136
+ return {"version": 1, "account_id": self.account_id, "imports": {}, "active": {}}
137
+ value = self._read_json(path)
138
+ if value.get("version") != 1 or value.get("account_id") != self.account_id:
139
+ raise ResourceError("observation index account/schema mismatch")
140
+ return value
141
+
142
+ def _commit_index(self, value):
143
+ raw = json.dumps(value, ensure_ascii=False, sort_keys=True).encode()
144
+ if len(raw) > 16*1024*1024:
145
+ raise ResourceError("checkpoint size limit")
146
+ if (self.root / "index.json").is_symlink():
147
+ raise ResourceError("checkpoint symlink rejected")
148
+ fd, temporary = tempfile.mkstemp(dir=self.root, suffix=".tmp")
149
+ try:
150
+ with os.fdopen(fd, "wb") as stream:
151
+ stream.write(raw)
152
+ stream.flush()
153
+ os.fsync(stream.fileno())
154
+ os.replace(temporary, self.root / "index.json")
155
+ self._sync_directory(self.root)
156
+ finally:
157
+ if os.path.exists(temporary):
158
+ os.unlink(temporary)
159
+
160
+ def _immutable_file(self, path, data):
161
+ if path.exists() or path.is_symlink():
162
+ if read_supplied(path, max(len(data), MAX_CSV)) != data:
163
+ raise ResourceError("immutable observation file conflict")
164
+ else:
165
+ save_download(path, data)
166
+
167
+ def import_csv(self, data, binding):
168
+ # Canonicalize supplied metadata before checking ownership or persistence.
169
+ if binding.get("source") == "youtubeStudio":
170
+ binding = StudioMetadata.model_validate(binding).model_dump(mode="json")
171
+ binding = json.loads(json.dumps(binding, allow_nan=False))
172
+ # This observation describes authorization at read time, not the report's
173
+ # immutable identity. A changed grant/expiry must not defeat report dedupe.
174
+ credentials = binding.pop("credential_observation", None)
175
+ if binding.get("account_id") != self.account_id:
176
+ raise ResourceError("import account mismatch")
177
+ source = binding.get("source")
178
+ if source == "youtubeReporting":
179
+ report = binding.get("report", {})
180
+ rid, job, rtype = opaque(report.get("id")), opaque(binding.get("job_id")), opaque(binding.get("report_type_id"))
181
+ if report.get("jobId") != job:
182
+ raise ResourceError("report/job binding mismatch")
183
+ start, end = report_period(report)
184
+ created = instant(report.get("createTime")).isoformat()
185
+ period = [source, self.account_id, job, rtype, start.isoformat(), end.isoformat()]
186
+ identifier = [source, self.account_id, job, rid]
187
+ elif source == "youtubeStudio":
188
+ rid = opaque(binding.get("export_id"))
189
+ start, end = date.fromisoformat(binding["start_date"]), date.fromisoformat(binding["end_date"])
190
+ if start > end:
191
+ raise ResourceError("invalid supplied export dates")
192
+ created = instant(binding.get("exported_at")).isoformat()
193
+ if not isinstance(binding.get("source_filename"), str) or not binding["source_filename"]:
194
+ raise ResourceError("supplied source filename required")
195
+ period = [source, self.account_id, rid, start.isoformat(), end.isoformat()]
196
+ identifier = [source, self.account_id, rid]
197
+ else:
198
+ raise ResourceError("unsupported CSV provenance source")
199
+ parsed = parse_csv(data, binding)
200
+ key = digest(json.dumps(identifier).encode())
201
+ period_key = digest(json.dumps(period).encode())
202
+ checksum = digest(data)
203
+ binding_digest = digest(json.dumps(binding, sort_keys=True).encode())
204
+ with self.apply_lock(str(uuid.uuid5(uuid.NAMESPACE_URL, "youtube-observations-index"))):
205
+ index = self.index()
206
+ existing = index["imports"].get(key)
207
+ if existing:
208
+ if (existing["sha256"], existing["binding_sha256"]) != (checksum, binding_digest):
209
+ raise ResourceError("same report ID has different bytes/binding; refuses overwrite")
210
+ return self._read_json(self.root / (key + ".json"))
211
+ p = provenance(source, self.account_id)
212
+ p.update({"account_binding": "authenticated_report_job_chain" if source == "youtubeReporting" else "user_supplied_not_API_verified",
213
+ "definitions_url": DEFINITIONS + "metrics" if source == "youtubeReporting" else None,
214
+ "dimensions": [h for h in parsed["headers"] if parsed["columns"][h]["kind"] == "dimension"],
215
+ "latency": "first report up to 48h; generated files only" if source == "youtubeReporting" else "user_supplied_export",
216
+ "historical_coverage": "provider generated; normal downloads 60 days, historical 30 days; not promised lifetime" if source == "youtubeReporting" else "declared supplied period"})
217
+ if source == "youtubeReporting":
218
+ p["credential_observation"] = credentials if credentials is not None else credential_metadata({})
219
+ if source == "youtubeStudio":
220
+ p["retention"] = {"category": "user_supplied_export", "api_cleanup_eligible": False,
221
+ "originals": "supplied original files are never changed or removed"}
222
+ observation = {"id": key, "binding": binding, "sha256": checksum, "provenance": p, **parsed}
223
+ serialized = json.dumps(observation, ensure_ascii=False, allow_nan=False).encode()
224
+ if len(serialized) > 128*1024*1024:
225
+ raise ResourceError("parsed observation byte limit; no checkpoint committed")
226
+ # A crash can leave orphan immutable files, but only the final atomic
227
+ # index replacement activates a complete import. Recovery reuses bytes.
228
+ raw_path = self.root / (key + ".csv")
229
+ parsed_path = self.root / (key + ".json")
230
+ self._immutable_file(raw_path, data)
231
+ if parsed_path.exists():
232
+ orphan = self._read_json(parsed_path)
233
+ if orphan.get("sha256") != checksum or orphan.get("binding") != binding:
234
+ raise ResourceError("orphan import binding conflict")
235
+ observation = orphan
236
+ else:
237
+ self._immutable_file(parsed_path, serialized)
238
+ index["imports"][key] = {"sha256": checksum, "binding_sha256": binding_digest, "period_key": period_key, "created_at": created, "checkpoint": "complete"}
239
+ previous = index["active"].get(period_key)
240
+ if previous is None or created > index["imports"][previous]["created_at"]:
241
+ index["active"][period_key] = key
242
+ elif created == index["imports"][previous]["created_at"]:
243
+ raise ResourceError("ambiguous equal-time backfill; no active version guessed")
244
+ self._commit_index(index)
245
+ return observation
246
+
247
+ def save_query(self, result):
248
+ if result.get("provenance", {}).get("account_id") != self.account_id:
249
+ raise ResourceError("query account mismatch")
250
+ self._ensure_root()
251
+ data = json.dumps(result, ensure_ascii=False, allow_nan=False).encode()
252
+ if len(data) > 128*1024*1024:
253
+ raise ResourceError("query observation byte limit")
254
+ key = digest(data)
255
+ self._immutable_file(self.root / (key + ".query.json"), data)
256
+ return {"id": key, "path": str(self.root / (key + ".query.json")), "complete": result["complete"]}
257
+
258
+ def active(self):
259
+ index = self.index()
260
+ return [self._read_json(self.root / (key + ".json")) for key in index["active"].values()]