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,403 @@
1
+ """Read YouTube metrics via Data API for catalog/cumulative stats and Analytics API for bounded period metrics and enrichment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import date, datetime, timezone
7
+
8
+ import httpx
9
+
10
+ from socialctl.adapters.errores import mensaje_de_error
11
+ from socialctl.auth import obtener_token
12
+ from socialctl.brands import Brand
13
+ from socialctl.metricas.base import Lector, SinPermiso
14
+ from socialctl.metricas.modelos import (
15
+ Audiencia,
16
+ Cuenta,
17
+ EstadoLectura,
18
+ LecturaRed,
19
+ Metricas,
20
+ Pieza,
21
+ TipoPieza,
22
+ )
23
+ from socialctl.models import Platform
24
+ from socialctl.read_budget import ReadDeadlineExceeded
25
+
26
+ ANALYTICS = "https://youtubeanalytics.googleapis.com/v2/reports"
27
+ CANALES = "https://www.googleapis.com/youtube/v3/channels"
28
+ VIDEOS = "https://www.googleapis.com/youtube/v3/videos"
29
+
30
+ #: Fecha lo bastante temprana como para cubrir la vida del canal.
31
+ INICIO_DE_LOS_TIEMPOS = "2005-02-14" # YouTube no existía antes
32
+
33
+ #: La Data API acepta como mucho 50 ids por petición de `videos.list`.
34
+ LOTE = 50
35
+
36
+ #: Tope que impone la propia API al informe de "top videos": «Top videos
37
+ #: reports require a sort parameter and a maxResults value of 200 or less»
38
+ #: (developers.google.com/youtube/analytics/channel_reports). Sin `maxResults`
39
+ #: y sin `sort`, la petición responde 400 y no llega ninguna pieza.
40
+ #:
41
+ #: **Consecuencia declarada:** hoy no se pagina. Un canal con más de 200
42
+ #: vídeos no se lee entero: se leen **los 200 más vistos** (por `sort=-views`)
43
+ #: y los demás no aparecen en el snapshot ni en `piezas.yml`. Ninguna marca de
44
+ #: este proyecto se acerca a esa cifra; cuando alguna lo haga, la solución es
45
+ #: paginar con `startIndex` (1, 201, 401…) hasta agotar los resultados, no
46
+ #: subir este número.
47
+ MAX_VIDEOS = 200
48
+
49
+ #: **Coste de cuota, declarado.** La curva de retención y las fuentes de
50
+ #: tráfico se piden **una petición por pieza cada una** (ver `_retencion` y
51
+ #: `_trafico`): con el techo de `MAX_VIDEOS`, una ejecución puede hacer hasta
52
+ #: 2 × 200 = 400 peticiones a Analytics, además de las del catálogo. Es el
53
+ #: precio de las dos únicas lecturas que **explican** el resultado en vez de
54
+ #: contarlo, y se paga a propósito; lo que el spec (§9) pide -no gastar cuota
55
+ #: de más- se cumple pidiendo solo las métricas necesarias y agrupando
56
+ #: `videos.list` en lotes de 50, no renunciando a la retención.
57
+ #:
58
+ #: **Qué pasa si la cuota se agota antes del bucle** (canal, informe por
59
+ #: vídeo, catálogo): sigue tumbando la lectura entera -sin esas tres
60
+ #: llamadas no hay nada que devolver-, exactamente igual que cualquier otro
61
+ #: fallo ahí. `leer_red` lo convierte en `estado: error` **solo para
62
+ #: YouTube**; las otras tres redes se leen igual y el snapshot del día se
63
+ #: escribe. El remedio es esperar al siguiente día de cuota o acotar con
64
+ #: `--desde`.
65
+ #:
66
+ #: **Qué pasa si se agota DENTRO del bucle (degradar, no perder).** Cada
67
+ #: pieza ya trae sus métricas base -las del informe por vídeo, sin coste
68
+ #: adicional- antes de intentar su retención y su tráfico: perder esas dos
69
+ #: peticiones no tiene por qué tirar lo que ya se pagó. `leer()` aísla el
70
+ #: enriquecimiento de cada pieza (ver `_enriquecer`): si falla, esa pieza se
71
+ #: queda con `curva_retencion: []` y `fuentes_trafico: {}` -nunca
72
+ #: inventadas, la regla de oro del módulo- y el bucle sigue con la
73
+ #: siguiente. Si el fallo es justo cuota agotada -`_pedir` ya distingue hoy
74
+ #: «sin permiso» (`SinPermiso`) de «cualquier otro 403», y ese «cualquier
75
+ #: otro 403» es, por descarte, cuota- se deja de pedir retención y tráfico
76
+ #: para el resto de piezas pendientes en vez de insistir 250 veces más:
77
+ #: `_CuotaAgotada` (ver su docstring) reutiliza ese mismo criterio, no uno
78
+ #: nuevo. Ninguna pieza se pierde; solo se queda sin las dos métricas que
79
+ #: no se pudieron pagar.
80
+ #:
81
+ # Optional enrichment failures are recorded on affected pieces. Channel-level
82
+ # audience failures are recorded in LecturaRed.warnings. Both are visible in
83
+ # structured coverage and reports; neither discards validated base metrics.
84
+ # Once a read deadline expires, remaining enrichment and audience requests stop.
85
+ CLAVE_ENRIQUECIMIENTO_FALLIDO = "enriquecimiento_fallido"
86
+
87
+ _DURACION = re.compile(
88
+ r"^P(?:(?P<d>\d+)D)?T(?:(?P<h>\d+)H)?(?:(?P<m>\d+)M)?(?:(?P<s>\d+)S)?$"
89
+ )
90
+
91
+
92
+ def _segundos(iso8601: str) -> int | None:
93
+ """Convert YouTube ISO 8601 duration to seconds."""
94
+ m = _DURACION.match(iso8601 or "")
95
+ if not m:
96
+ return None
97
+ partes = {k: int(v) for k, v in m.groupdict(default="0").items()}
98
+ return partes["d"] * 86400 + partes["h"] * 3600 + partes["m"] * 60 + partes["s"]
99
+
100
+
101
+ def _entero(valor: object) -> int | None:
102
+ """Convert available data to integer; absent metrics remain None."""
103
+ if valor is None:
104
+ return None
105
+ try:
106
+ return int(valor)
107
+ except (TypeError, ValueError):
108
+ return None
109
+
110
+
111
+ def _filas(respuesta: dict) -> list[list]:
112
+ return respuesta.get("rows") or []
113
+
114
+
115
+ class _CuotaAgotada(RuntimeError):
116
+ """Non-scope YouTube HTTP failure, distinct from SinPermiso so only actual quota exhaustion stops later enrichment."""
117
+
118
+
119
+ class YouTubeLector(Lector):
120
+ platform = Platform.YOUTUBE
121
+
122
+ def leer(
123
+ self, brand: Brand, client: httpx.Client, desde: date | None
124
+ ) -> LecturaRed:
125
+ token = obtener_token(brand, self.platform, client)
126
+ cabeceras = {"Authorization": f"Bearer {token}"}
127
+ hoy = date.today()
128
+
129
+ canal = self._canal(client, cabeceras)
130
+ cuenta = Cuenta(
131
+ seguidores=_entero(canal["statistics"].get("subscriberCount")),
132
+ total_piezas=_entero(canal["statistics"].get("videoCount")),
133
+ total_vistas=_entero(canal["statistics"].get("viewCount")),
134
+ )
135
+
136
+ por_video = self._analytics(
137
+ client, cabeceras, hoy,
138
+ dimensions="video",
139
+ metrics=(
140
+ "views,estimatedMinutesWatched,averageViewDuration,"
141
+ "averageViewPercentage,subscribersGained,likes,comments,shares"
142
+ ),
143
+ # Obligatorios en el informe por vídeo, y solo en él: sin los dos
144
+ # la API responde 400 (ver MAX_VIDEOS). Los otros tres informes
145
+ # de este lector -retención, tráfico, audiencia- no los admiten
146
+ # como requisito y no se los mandamos.
147
+ maxResults=str(MAX_VIDEOS),
148
+ sort="-views",
149
+ )
150
+ ids = [fila[0] for fila in _filas(por_video)]
151
+ catalogo = self._videos(client, cabeceras, ids)
152
+
153
+ piezas = []
154
+ # Una vez agotada la cuota, ninguna petición más de este tipo va a
155
+ # funcionar: se deja de intentar (ni siquiera se llama a `_pedir`)
156
+ # para el resto de piezas pendientes, que se conservan igual con sus
157
+ # métricas base. Ver el bloque de MAX_VIDEOS para el porqué.
158
+ cuota_agotada = False
159
+ deadline_error = None
160
+ warnings = []
161
+ # Collect channel-level audience before expensive per-video enrichment.
162
+ audiencia = None
163
+ try:
164
+ audiencia = self._audiencia(client, cabeceras, hoy)
165
+ except Exception as exc:
166
+ from socialctl.read_reports import safe_error
167
+ warnings.append('Audience unavailable: ' + safe_error(str(exc)))
168
+ if isinstance(exc, ReadDeadlineExceeded):
169
+ deadline_error = exc
170
+ for fila in _filas(por_video):
171
+ pieza = self._pieza(fila, catalogo)
172
+ if pieza is None:
173
+ continue
174
+ if desde is not None and pieza.publicado_el.date() < desde:
175
+ continue
176
+
177
+ if cuota_agotada or deadline_error:
178
+ pieza.especificas["curva_retencion"] = []
179
+ pieza.especificas["fuentes_trafico"] = {}
180
+ pieza.especificas[CLAVE_ENRIQUECIMIENTO_FALLIDO] = (
181
+ str(deadline_error) if deadline_error else
182
+ "not requested: Analytics quota was already exhausted while "
183
+ "enriching an earlier item"
184
+ )
185
+ else:
186
+ try:
187
+ self._enriquecer(client, cabeceras, hoy, pieza)
188
+ except _CuotaAgotada:
189
+ cuota_agotada = True
190
+ except ReadDeadlineExceeded as exc:
191
+ deadline_error = exc
192
+
193
+ piezas.append(pieza)
194
+
195
+ # Reporting reports arrive as immutable CSV imports. They are a
196
+ # separate, delayed source; no report means unknown, never zero.
197
+ from socialctl.metricas.analytics_ingest import ObservationStore
198
+ from socialctl.metricas.reach import attach_reach
199
+ from socialctl.management.youtube_resources import ResourceError
200
+ authenticated_channel = canal.get('id')
201
+ configured_channel = (brand.cuentas.get('youtube') or {}).get('channel_id')
202
+ if configured_channel and configured_channel != authenticated_channel:
203
+ raise ValueError('YouTube Reporting account differs from authenticated channel')
204
+ observations = ObservationStore(brand.raiz, authenticated_channel)
205
+ if observations.root.exists():
206
+ try:
207
+ attach_reach(piezas, observations.active(), hoy)
208
+ except (ResourceError, ValueError, KeyError, TypeError):
209
+ for pieza in piezas:
210
+ pieza.especificas['thumbnail_reach_error'] = (
211
+ 'imported reach report unreadable; impressions/CTR unknown')
212
+
213
+ return LecturaRed(
214
+ estado=EstadoLectura.OK,
215
+ account_id=authenticated_channel,
216
+ cuenta=cuenta,
217
+ piezas=piezas,
218
+ audiencia=audiencia,
219
+ warnings=warnings,
220
+ )
221
+
222
+ # --- peticiones -----------------------------------------------------
223
+
224
+ def _pedir(self, client: httpx.Client, url: str, cabeceras: dict, params: dict) -> dict:
225
+ """Request data and distinguish missing granted scope from other errors using sanitized messages."""
226
+ r = client.get(url, headers=cabeceras, params=params)
227
+ if r.is_success:
228
+ return r.json()
229
+
230
+ token = cabeceras.get("Authorization", "").removeprefix("Bearer ")
231
+
232
+ if r.status_code == 403:
233
+ # Google devuelve 403 tanto por scope insuficiente como por
234
+ # cuota agotada; solo el primero se arregla reautenticando, así
235
+ # que se distinguen por el texto antes de decirle al usuario qué
236
+ # hacer.
237
+ cuerpo = r.text
238
+ if "insufficient" in cuerpo.lower() or "PERMISSION_DENIED" in cuerpo:
239
+ scope = (
240
+ "yt-analytics.readonly" if url == ANALYTICS else "youtube.readonly"
241
+ )
242
+ raise SinPermiso(self.platform, scope)
243
+
244
+ # Cualquier otro 403 es, por descarte, el caso que importa: la
245
+ # cuota diaria agotada. `_CuotaAgotada` es una `RuntimeError`
246
+ # más -mismo mensaje, mismo comportamiento para quien no la
247
+ # distinga-, pero permite que `leer()` deje de insistir con el
248
+ # resto de piezas sin repetir aquí ningún criterio nuevo.
249
+ raise _CuotaAgotada(
250
+ f"YouTube returned {r.status_code} for {url}: "
251
+ f"{mensaje_de_error(r, token)}"
252
+ )
253
+
254
+ raise RuntimeError(
255
+ f"YouTube returned {r.status_code} for {url}: "
256
+ f"{mensaje_de_error(r, token)}"
257
+ )
258
+
259
+ def _canal(self, client: httpx.Client, cabeceras: dict) -> dict:
260
+ datos = self._pedir(client, CANALES, cabeceras, {
261
+ "part": "snippet,statistics,contentDetails",
262
+ "mine": "true",
263
+ })
264
+ items = datos.get("items") or []
265
+ if not items:
266
+ raise RuntimeError(
267
+ "Data API returned no channel for this token: "
268
+ "check that you authorized the correct account"
269
+ )
270
+ return items[0]
271
+
272
+ def _analytics(
273
+ self, client: httpx.Client, cabeceras: dict, hoy: date, **extra: str
274
+ ) -> dict:
275
+ params = {
276
+ "ids": "channel==MINE",
277
+ "startDate": INICIO_DE_LOS_TIEMPOS,
278
+ "endDate": hoy.isoformat(),
279
+ **extra,
280
+ }
281
+ return self._pedir(client, ANALYTICS, cabeceras, params)
282
+
283
+ def _videos(self, client: httpx.Client, cabeceras: dict, ids: list[str]) -> dict[str, dict]:
284
+ catalogo: dict[str, dict] = {}
285
+ for i in range(0, len(ids), LOTE):
286
+ datos = self._pedir(client, VIDEOS, cabeceras, {
287
+ "part": "snippet,statistics,contentDetails",
288
+ "id": ",".join(ids[i : i + LOTE]),
289
+ })
290
+ for item in datos.get("items") or []:
291
+ catalogo[item["id"]] = item
292
+ return catalogo
293
+
294
+ # --- composición ----------------------------------------------------
295
+
296
+ def _pieza(self, fila: list, catalogo: dict[str, dict]) -> Pieza | None:
297
+ id_video = fila[0]
298
+ item = catalogo.get(id_video)
299
+ if item is None:
300
+ # El vídeo salió en Analytics pero no en el catálogo: borrado o
301
+ # privado. No se inventa nada: se omite.
302
+ return None
303
+
304
+ duracion = _segundos(item["contentDetails"].get("duration", ""))
305
+ estadisticas = item.get("statistics", {})
306
+ publicado = datetime.fromisoformat(
307
+ item["snippet"]["publishedAt"].replace("Z", "+00:00")
308
+ ).astimezone(timezone.utc).replace(tzinfo=None)
309
+
310
+ return Pieza(
311
+ id=id_video,
312
+ url=f"https://www.youtube.com/watch?v={id_video}",
313
+ titulo=item["snippet"]["title"],
314
+ publicado_el=publicado,
315
+ tipo=(
316
+ # 180 s y no 60: desde octubre de 2024 YouTube admite Shorts
317
+ # de hasta **3 minutos**, así que el umbral de 60 s dejaría
318
+ # clasificados como "largo" cortes que son Shorts a todos los
319
+ # efectos. Un vídeo horizontal de dos minutos caería del lado
320
+ # equivocado, pero la API no da la relación de aspecto en
321
+ # `contentDetails`, y en este proyecto no se publican
322
+ # horizontales tan cortos.
323
+ TipoPieza.VERTICAL
324
+ if duracion is not None and duracion <= 180
325
+ else TipoPieza.LARGO
326
+ ),
327
+ duracion_seg=duracion,
328
+ acumulado=Metricas(
329
+ vistas=_entero(estadisticas.get("viewCount")),
330
+ likes=_entero(estadisticas.get("likeCount")),
331
+ comentarios=_entero(estadisticas.get("commentCount")),
332
+ compartidos=_entero(fila[8]) if len(fila) > 8 else None,
333
+ ),
334
+ especificas={
335
+ "minutos_vistos": _entero(fila[2]),
336
+ "duracion_media_seg": _entero(fila[3]),
337
+ "porcentaje_visto": fila[4] if len(fila) > 4 else None,
338
+ "suscriptores_ganados": _entero(fila[5]) if len(fila) > 5 else None,
339
+ },
340
+ )
341
+
342
+ def _retencion(
343
+ self, client: httpx.Client, cabeceras: dict, hoy: date, id_video: str
344
+ ) -> list[dict]:
345
+ datos = self._analytics(
346
+ client, cabeceras, hoy,
347
+ dimensions="elapsedVideoTimeRatio",
348
+ metrics="audienceWatchRatio",
349
+ filters=f"video=={id_video};audienceType==ORGANIC",
350
+ )
351
+ return [{"posicion": fila[0], "ratio": fila[1]} for fila in _filas(datos)]
352
+
353
+ def _trafico(
354
+ self, client: httpx.Client, cabeceras: dict, hoy: date, id_video: str
355
+ ) -> dict[str, int]:
356
+ datos = self._analytics(
357
+ client, cabeceras, hoy,
358
+ dimensions="insightTrafficSourceType",
359
+ metrics="views",
360
+ filters=f"video=={id_video}",
361
+ )
362
+ return {fila[0]: _entero(fila[1]) for fila in _filas(datos)}
363
+
364
+ def _enriquecer(
365
+ self, client: httpx.Client, cabeceras: dict, hoy: date, pieza: Pieza
366
+ ) -> None:
367
+ """Enrich one item with retention and traffic; both requests must succeed, otherwise record failure without partial enrichment."""
368
+ try:
369
+ pieza.especificas["curva_retencion"] = self._retencion(
370
+ client, cabeceras, hoy, pieza.id
371
+ )
372
+ pieza.especificas["fuentes_trafico"] = self._trafico(
373
+ client, cabeceras, hoy, pieza.id
374
+ )
375
+ except Exception as e: # noqa: BLE001 - aísla esta pieza, no tumba la lectura
376
+ pieza.especificas["curva_retencion"] = []
377
+ pieza.especificas["fuentes_trafico"] = {}
378
+ pieza.especificas[CLAVE_ENRIQUECIMIENTO_FALLIDO] = str(e)
379
+ if isinstance(e, (_CuotaAgotada, ReadDeadlineExceeded)):
380
+ raise
381
+
382
+ def _audiencia(self, client: httpx.Client, cabeceras: dict, hoy: date) -> Audiencia:
383
+ paises_crudos = self._analytics(
384
+ # `sort=-views` no es decorativo: el spec pide el **top 10** de
385
+ # países, y sin ordenar serían "los 10 primeros que devuelva la
386
+ # API", que es otra cosa.
387
+ client, cabeceras, hoy, dimensions="country", metrics="views",
388
+ sort="-views",
389
+ )
390
+ filas = _filas(paises_crudos)[:10]
391
+ total = sum(fila[1] for fila in filas) or 0
392
+ paises = (
393
+ {fila[0]: round(fila[1] * 100 / total, 1) for fila in filas}
394
+ if total
395
+ else {}
396
+ )
397
+
398
+ edades_crudas = self._analytics(
399
+ client, cabeceras, hoy, dimensions="ageGroup", metrics="viewerPercentage"
400
+ )
401
+ edades = {fila[0]: fila[1] for fila in _filas(edades_crudas)}
402
+
403
+ return Audiencia(paises=paises, edades=edades)
@@ -0,0 +1,60 @@
1
+ """Operator-facing migration lifecycle; no publication or remote writes."""
2
+ from pathlib import Path
3
+ import json
4
+
5
+ import typer
6
+
7
+ from socialctl.brands import cargar_brand
8
+ from socialctl import queue_migration
9
+
10
+ migration_app = typer.Typer(help="Relocate media and migrate verified legacy approvals.")
11
+ from socialctl.workspace import default_root
12
+
13
+ DEFAULT_ROOT = default_root()
14
+
15
+
16
+ def _run(operation):
17
+ try:
18
+ return operation()
19
+ except Exception as exc:
20
+ typer.echo(f"Migration error: {exc}", err=True)
21
+ raise typer.Exit(1) from exc
22
+
23
+
24
+ @migration_app.command("prepare")
25
+ def prepare(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(DEFAULT_ROOT),
26
+ backend: str = typer.Option("json"), dry_run: bool = typer.Option(False, "--dry-run")):
27
+ proposal = _run(lambda: queue_migration.prepare(cargar_brand(root, brand), backend=backend, dry_run=dry_run))
28
+ typer.echo(proposal["preview"])
29
+ typer.echo("\nExact path mapping:")
30
+ for item in proposal["media"]:
31
+ typer.echo(f"{item['source']} -> media/{item['relative_path']} -> {item['public_url']} ({item['size']} bytes, SHA256 {item['sha256']})")
32
+ typer.echo("\n" + proposal["legacy_limitation"])
33
+ for entry in proposal["entries"]:
34
+ typer.echo(f"{entry['id']} | {entry['status']} | {entry['scheduled_at']} | attempts={entry['attempts']}")
35
+ typer.echo(f"Proposal: {proposal['id']}\nDigest: {proposal['digest']}")
36
+ typer.echo("Dry-run: no proposal saved or queue modified." if dry_run else "Proposal saved; queue unchanged and no content published.")
37
+
38
+
39
+ @migration_app.command("stage")
40
+ def stage(brand: str = typer.Option(..., "--brand"), proposal: str = typer.Option(...),
41
+ output: Path = typer.Option(...), root: Path = typer.Option(DEFAULT_ROOT)):
42
+ manifest = _run(lambda: queue_migration.stage(cargar_brand(root, brand), proposal, output))
43
+ typer.echo(json.dumps(manifest, indent=2, ensure_ascii=False))
44
+ typer.echo(f"Transfer manifest: {output / 'transfer-manifest.json'}; remote transfer is the operator's responsibility.")
45
+
46
+
47
+ @migration_app.command("apply")
48
+ def apply(brand: str = typer.Option(..., "--brand"), proposal: str = typer.Option(...),
49
+ digest: str = typer.Option(...), root: Path = typer.Option(DEFAULT_ROOT),
50
+ acknowledge_legacy_limitations: bool = typer.Option(False, "--acknowledge-legacy-limitations")):
51
+ intent = _run(lambda: queue_migration.apply(cargar_brand(root, brand), proposal, digest,
52
+ acknowledge_legacy_limitations=acknowledge_legacy_limitations))
53
+ typer.echo(f"Migration {proposal}: {intent['status']}; approvals, accounts and schedules preserved.")
54
+
55
+
56
+ @migration_app.command("rollback")
57
+ def rollback(brand: str = typer.Option(..., "--brand"), proposal: str = typer.Option(...),
58
+ root: Path = typer.Option(DEFAULT_ROOT)):
59
+ _run(lambda: queue_migration.rollback(cargar_brand(root, brand), proposal))
60
+ typer.echo(f"Migration {proposal} restored. Supplied media and copies preserved.")
@@ -0,0 +1,68 @@
1
+ """Durable migration files and byte-preserving YAML scalar relocation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from socialctl.scheduler import ScheduleError, ScheduleStore
12
+
13
+
14
+ def durable_write(path: Path, data: bytes) -> None:
15
+ missing = []
16
+ parent = path.parent
17
+ while not parent.exists():
18
+ missing.append(parent)
19
+ parent = parent.parent
20
+ for directory in reversed(missing):
21
+ directory.mkdir(exist_ok=True)
22
+ ScheduleStore._sync_directory(directory.parent)
23
+ fd, temporary = tempfile.mkstemp(prefix=".migration-", dir=path.parent)
24
+ try:
25
+ with os.fdopen(fd, "wb") as handle:
26
+ handle.write(data)
27
+ handle.flush()
28
+ os.fsync(handle.fileno())
29
+ os.replace(temporary, path)
30
+ ScheduleStore._sync_directory(path.parent)
31
+ finally:
32
+ Path(temporary).unlink(missing_ok=True)
33
+
34
+
35
+ def write_json(path: Path, value: dict) -> None:
36
+ durable_write(path, (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode())
37
+
38
+
39
+ def rewrite_media(raw: bytes, replacements: dict[str, dict[str, str]]) -> bytes:
40
+ """Replace only media scalar tokens. Aliases/anchors/duplicate keys fail closed."""
41
+ text = raw.decode("utf-8")
42
+ if any(isinstance(token, (yaml.tokens.AnchorToken, yaml.tokens.AliasToken)) for token in yaml.scan(text)):
43
+ raise ScheduleError("YAML aliases/anchors do not support safe migration")
44
+ root = yaml.compose(text)
45
+ def mapping(node):
46
+ if not isinstance(node, yaml.MappingNode):
47
+ raise ScheduleError("unsupported YAML structure")
48
+ result = {}
49
+ for key, value in node.value:
50
+ if not isinstance(key, yaml.ScalarNode) or key.value in result or key.value == "<<":
51
+ raise ScheduleError("ambiguous YAML keys")
52
+ result[key.value] = value
53
+ if isinstance(value, yaml.MappingNode):
54
+ mapping(value)
55
+ return result
56
+ platforms = mapping(mapping(root)["platforms"])
57
+ spans = []
58
+ for platform, paths in replacements.items():
59
+ media = mapping(platforms[platform]).get("media")
60
+ if not isinstance(media, yaml.SequenceNode):
61
+ raise ScheduleError("media must be an explicit list")
62
+ for item in media.value:
63
+ if not isinstance(item, yaml.ScalarNode) or item.tag != "tag:yaml.org,2002:str" or item.value not in paths:
64
+ raise ScheduleError("ambiguous or modified YAML media")
65
+ spans.append((item.start_mark.index, item.end_mark.index, json.dumps(paths[item.value])))
66
+ for start, end, replacement in sorted(spans, reverse=True):
67
+ text = text[:start] + replacement + text[end:]
68
+ return text.encode("utf-8")
socialctl/models.py ADDED
@@ -0,0 +1,150 @@
1
+ """Shared socialctl data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, Field, field_validator, model_validator
11
+
12
+
13
+ class Platform(str, Enum):
14
+ YOUTUBE = "youtube"
15
+ FACEBOOK = "facebook"
16
+ INSTAGRAM = "instagram"
17
+ TIKTOK = "tiktok"
18
+
19
+
20
+ class MediaKind(str, Enum):
21
+ VIDEO = "video"
22
+ IMAGE = "image"
23
+
24
+
25
+ class CampaignType(str, Enum):
26
+ CLIP_VERTICAL = "clip-vertical"
27
+ LANZAMIENTO_VIDEO_LARGO = "lanzamiento-video-largo"
28
+ POST_IMAGEN = "post-imagen"
29
+
30
+
31
+ class PostStatus(str, Enum):
32
+ PUBLICADO = "publicado"
33
+ PENDIENTE_CONFIRMACION = "pendiente_confirmacion"
34
+ ERROR = "error"
35
+ OMITIDO = "omitido"
36
+
37
+
38
+ class MediaAsset(BaseModel):
39
+ probe: dict | None = Field(default=None, exclude=True)
40
+ """Read-only technical observations, excluded from durable approval payloads."""
41
+ path: Path
42
+ kind: MediaKind
43
+ width: int | None = None
44
+ height: int | None = None
45
+ duration_s: float | None = None
46
+ size_bytes: int = 0
47
+ ruta_relativa: str = ""
48
+ """Original relative media path, including subdirectories. Empty values fall back through ruta_relativa_efectiva."""
49
+
50
+
51
+ def __eq__(self, other):
52
+ # Ephemeral inspection details are not part of durable media identity.
53
+ if isinstance(other, MediaAsset):
54
+ return BaseModel.__eq__(self.model_copy(update={"probe": None}),
55
+ other.model_copy(update={"probe": None}))
56
+ return NotImplemented
57
+
58
+
59
+ class StoryPost(BaseModel):
60
+ """A standalone Story, separate from feed and Reel posts."""
61
+
62
+ platform: Platform
63
+ media: MediaAsset
64
+ public_url: str | None = None
65
+ text: str | None = None
66
+ expires_at: datetime
67
+ source_video_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{11}$")
68
+
69
+ @model_validator(mode="after")
70
+ def validate_story(self):
71
+ if self.platform not in {Platform.FACEBOOK, Platform.INSTAGRAM}:
72
+ raise ValueError("Stories are only supported on Facebook or Instagram")
73
+ if self.text is not None and not self.text.strip():
74
+ raise ValueError("optional Story text cannot be empty")
75
+ if self.expires_at.utcoffset() is None:
76
+ raise ValueError("expires_at must include a timezone")
77
+ return self
78
+
79
+
80
+ class PlatformPost(BaseModel):
81
+ platform: Platform
82
+ title: str | None = None
83
+ body: str
84
+ hashtags: list[str] = []
85
+ tags: list[str] | None = Field(default=None, exclude_if=lambda value: value is None)
86
+ visible_hashtags: list[str] | None = Field(default=None, exclude_if=lambda value: value is None)
87
+
88
+ @field_validator("tags", "visible_hashtags", mode="before")
89
+ @classmethod
90
+ def explicit_youtube_lists(cls, value):
91
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
92
+ raise ValueError("tags/visible_hashtags require lists of strings; omitting them preserves legacy mode")
93
+ return value
94
+
95
+ @model_validator(mode="after")
96
+ def validate_youtube_tags(self):
97
+ if self.platform != Platform.YOUTUBE and (self.tags is not None or self.visible_hashtags is not None):
98
+ raise ValueError("tags/visible_hashtags are only supported on YouTube")
99
+ if self.tags is not None and self.hashtags:
100
+ raise ValueError("YouTube: legacy hashtags and explicit tags are ambiguous; use one")
101
+ if self.visible_hashtags is not None:
102
+ for tag in self.visible_hashtags:
103
+ value = tag.removeprefix("#")
104
+ if not value or any(not (char.isalnum() or char == "_") for char in value):
105
+ raise ValueError("visible_hashtags requires each hashtag without spaces or punctuation")
106
+ return self
107
+
108
+ media: list[MediaAsset] = []
109
+ link: str | None = None
110
+ content_origin: Literal["standalone", "youtube_long"] | None = None
111
+ source_video_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{11}$")
112
+ first_comment: str | None = None
113
+ privacy: str | None = None
114
+ """Requested privacy where supported. YouTube uses this field and defaults to private; TikTok validates creator_info options separately."""
115
+
116
+
117
+ class Post(BaseModel):
118
+ source_mapping_digest: str | None = Field(default=None, exclude_if=lambda value: value is None)
119
+ slug: str
120
+ brand: str
121
+ campaign: CampaignType
122
+ platforms: dict[Platform, PlatformPost]
123
+
124
+
125
+ class PostResult(BaseModel):
126
+ platform: Platform
127
+ status: PostStatus
128
+ url: str | None = None
129
+ platform_id: str | None = None
130
+ error: str | None = None
131
+ warnings: list[str] = []
132
+ publication_id: str | None = None
133
+ first_comment_id: str | None = None
134
+ first_comment_change_id: str | None = None
135
+ first_comment_status: str | None = None
136
+ requested_privacy: str | None = None
137
+ observed_privacy: str | None = None
138
+ observed_publish_at: str | None = None
139
+ observed_processing_status: str | None = None
140
+ visibility_observed_at: str | None = None
141
+ riesgo_duplicado: bool = False
142
+ """True when content may already be published despite an error. Automatic retry must respect this structured duplicate-risk signal."""
143
+
144
+
145
+ class ValidationError(BaseModel):
146
+ """A specific validation failure detected before publication."""
147
+
148
+ platform: Platform
149
+ campo: str
150
+ motivo: str