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,251 @@
1
+ """Render daily snapshot as readable Markdown at <Brand>/metricas/resumen.md; detailed data remains in JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from socialctl.brands import Brand
8
+ from socialctl.metricas.almacen import (
9
+ dir_metricas,
10
+ escribir_atomico,
11
+ snapshot_anterior,
12
+ )
13
+ from socialctl.metricas.modelos import EstadoLectura, Metricas, Pieza, Snapshot
14
+ from socialctl.metricas.piezas import cargar_piezas
15
+ from socialctl.metricas.youtube import CLAVE_ENRIQUECIMIENTO_FALLIDO
16
+ from socialctl.models import Platform
17
+
18
+ COLUMNAS: dict[Platform, tuple[tuple[str, str], ...]] = {
19
+ # YouTube: retención y suscriptores, que es lo que mide el objetivo de
20
+ # los largos y el de crecer en suscriptores.
21
+ Platform.YOUTUBE: (
22
+ ("% viewed", "porcentaje_visto"),
23
+ ("Subs", "suscriptores_ganados"),
24
+ ),
25
+ # Instagram: el spec (§7) mide su objetivo -alcance- con vistas,
26
+ # compartidos y guardados. Sin estas dos columnas, el gestor tendría que
27
+ # bajar al JSON para lo que en esta red es lo central.
28
+ Platform.INSTAGRAM: (("Shares", "compartidos"), ("Saves", "guardados")),
29
+ # TikTok: **no hay columna de guardados, y no es un olvido.** La Display
30
+ # API de TikTok no expone esa métrica -ver el docstring de
31
+ # `socialctl/metricas/tiktok.py`-, verificado contra la cuenta real de
32
+ # la marca el 2026-09-11, no supuesto. Su lector nunca rellena
33
+ # `guardados` (queda en `None` por la regla de oro de
34
+ # `socialctl/metricas/modelos.py`), así que esta columna saldría en
35
+ # blanco en todas las filas, siempre, y nadie entendería por qué.
36
+ #
37
+ # No se sustituye por `likes` ni `comentarios` -las dos vivas en su
38
+ # lector-: ninguna mide alcance mejor que `compartidos` sola, así que
39
+ # añadir una sería relleno, no una columna que aporte (mismo criterio
40
+ # que `METRICAS_MUERTAS` en `socialctl/metricas/facebook.py`, que
41
+ # tampoco sustituye `alcance` por una métrica parecida). Si alguien
42
+ # reintenta esto: la Display API sigue sin dar guardados; compruébalo
43
+ # tú mismo contra la cuenta real antes de asumir que cambió.
44
+ Platform.TIKTOK: (("Shares", "compartidos"),),
45
+ # Facebook no da guardados, y desde la Graph API v26.0 tampoco da alcance
46
+ # por publicación (ver `METRICAS_MUERTAS` en `metricas/facebook.py`). Lo
47
+ # que sí da, y mide mejor el objetivo, es la retención media: cuántos
48
+ # segundos aguantan de media, en segundos ya convertidos.
49
+ Platform.FACEBOOK: (("Shares", "compartidos"), ("Retention s", "retencion_media_seg")),
50
+ }
51
+ """Use at most two platform-specific columns alongside the five common columns."""
52
+
53
+ #: Cuántos caracteres del motivo de un enriquecimiento fallido caben en la
54
+ #: celda de "Aviso" antes de recortar. La tabla tiene que seguir leyéndose de
55
+ #: un vistazo; el motivo completo -ya redactado, sin secretos- sigue estando
56
+ #: en el JSON del snapshot del día para quien necesite el texto entero.
57
+ LARGO_AVISO = 80
58
+
59
+ #: Cuántos caracteres del título de una pieza caben en su celda. Mismo
60
+ #: motivo que `LARGO_AVISO`: la tabla tiene que leerse de un vistazo.
61
+ LARGO_TITULO = 48
62
+
63
+
64
+ def _recortar(texto: str, largo: int) -> str:
65
+ """Truncate to largo characters at word boundaries, marking truncation with an ellipsis."""
66
+ if len(texto) <= largo:
67
+ return texto
68
+ recortado = texto[: largo - 1].rstrip()
69
+ espacio = recortado.rfind(" ")
70
+ if espacio > 0:
71
+ recortado = recortado[:espacio].rstrip()
72
+ return recortado + "…"
73
+
74
+
75
+ def _escapar(celda: str) -> str:
76
+ """Escape pipes and replace line breaks so text fits a Markdown table cell."""
77
+ return celda.replace("|", "\\|").replace("\n", " ").replace("\r", " ")
78
+
79
+
80
+ def _valor(pieza: Pieza | None, clave: str) -> object | None:
81
+ """Look up clave in common metrics first, then platform-specific metrics."""
82
+ if pieza is None:
83
+ return None
84
+ if clave in Metricas.model_fields:
85
+ return getattr(pieza.acumulado, clave)
86
+ return pieza.especificas.get(clave)
87
+
88
+
89
+ def _variacion(actual: object, previo: object) -> str:
90
+ """Format signed delta, or empty text when unchanged or unavailable."""
91
+ if not isinstance(actual, (int, float)) or not isinstance(previo, (int, float)):
92
+ return ""
93
+ diferencia = actual - previo
94
+ if isinstance(diferencia, float):
95
+ diferencia = round(diferencia, 1)
96
+ if diferencia == int(diferencia):
97
+ diferencia = int(diferencia)
98
+ if diferencia == 0:
99
+ return ""
100
+ return f"+{diferencia}" if diferencia > 0 else str(diferencia)
101
+
102
+
103
+ def _celda(valor: object) -> str:
104
+ return "—" if valor is None else str(valor)
105
+
106
+
107
+ def _celda_comparada(actual: object, previo: object) -> str:
108
+ """Format value with delta when an earlier matching item exists."""
109
+ base = _celda(actual)
110
+ delta = _variacion(actual, previo)
111
+ return f"{base} ({delta})" if delta else base
112
+
113
+
114
+ def _aviso(pieza: Pieza) -> str:
115
+ """Show enrichment failure reason or an em dash; never infer missing data as zero."""
116
+ motivo = pieza.especificas.get(CLAVE_ENRIQUECIMIENTO_FALLIDO)
117
+ if not motivo:
118
+ return "—"
119
+ return f"⚠ {_recortar(str(motivo), LARGO_AVISO)}"
120
+
121
+
122
+ def render_resumen(
123
+ snapshot: Snapshot, anterior: Snapshot | None, piezas: list[dict]
124
+ ) -> str:
125
+ editoriales = {(p.get("red"), p.get("id")): p for p in piezas}
126
+ lineas = [
127
+ f"# Metrics for {snapshot.marca}",
128
+ "",
129
+ f"**Snapshot:** {snapshot.fecha.isoformat()}",
130
+ ]
131
+
132
+ if anterior is None:
133
+ lineas.append(
134
+ "**Change:** no previous snapshot available for comparison "
135
+ "(first snapshot)."
136
+ )
137
+ else:
138
+ lineas.append(f"**Compared with:** {anterior.fecha.isoformat()}")
139
+ lineas.append("")
140
+
141
+ for platform, lectura in snapshot.redes.items():
142
+ lineas.append(f"## {platform.value}")
143
+ lineas.append("")
144
+ for warning in lectura.warnings:
145
+ lineas += [f"**Partial read:** {warning}", ""]
146
+ if lectura.estado is not EstadoLectura.OK:
147
+ lineas += [
148
+ f"**Status:** `{lectura.estado.value}` — {lectura.error or 'no details'}",
149
+ "",
150
+ ]
151
+ # El aviso de estado SÍ, pero la tabla también si hay piezas. Con
152
+ # `--only`, `_fusionar_snapshot_del_dia` (`socialctl/cli.py`)
153
+ # conserva los últimos números buenos de una red que hoy falla y
154
+ # le pone el `estado` de hoy; ese arreglo prometía que "el resumen
155
+ # y `piezas.yml` siguen viendo los últimos números buenos en vez
156
+ # de un hueco", y aquí no se cumplía: un `continue` incondicional
157
+ # borraba de `resumen.md` las piezas conservadas, la línea de
158
+ # seguidores y la tabla entera, justo en el escenario que el
159
+ # arreglo existe para evitar. Sin piezas no hay nada que enseñar
160
+ # y se sigue saltando la tabla, que es el caso de una red que
161
+ # falló sin dato previo.
162
+ if not lectura.piezas:
163
+ continue
164
+
165
+ if lectura.cuenta is not None:
166
+ lineas += [
167
+ f"Followers: {_celda(lectura.cuenta.seguidores)} · "
168
+ f"Items: {_celda(lectura.cuenta.total_piezas)} · "
169
+ f"Total views: {_celda(lectura.cuenta.total_vistas)}",
170
+ "",
171
+ ]
172
+
173
+ previas = {}
174
+ if anterior is not None and platform in anterior.redes:
175
+ previas = {p.id: p for p in anterior.redes[platform].piezas}
176
+
177
+ # `COLUMNAS` cubre las cuatro redes de `Platform`, que son todas las
178
+ # que puede traer un snapshot: no hay caso por defecto que atender.
179
+ columnas = COLUMNAS[platform]
180
+ titulos = ["Published", "Item", "Pillar", "Hook", "Views"]
181
+ titulos += [titulo for titulo, _ in columnas]
182
+ titulos.append("Notice")
183
+ lineas += [
184
+ "| " + " | ".join(titulos) + " |",
185
+ "|" + "---|" * len(titulos),
186
+ ]
187
+ for pieza in sorted(lectura.piezas, key=lambda p: p.publicado_el, reverse=True):
188
+ editorial = editoriales.get((platform.value, pieza.id), {})
189
+ previa = previas.get(pieza.id)
190
+ celdas = [
191
+ pieza.publicado_el.date().isoformat(),
192
+ _recortar(pieza.titulo, LARGO_TITULO),
193
+ _celda(editorial.get("pilar")),
194
+ _celda(editorial.get("gancho")),
195
+ _celda_comparada(
196
+ pieza.acumulado.vistas,
197
+ previa.acumulado.vistas if previa else None,
198
+ ),
199
+ ]
200
+ for _, clave in columnas:
201
+ celdas.append(
202
+ _celda_comparada(
203
+ _valor(pieza, clave),
204
+ _valor(previa, clave) if previa else None,
205
+ )
206
+ )
207
+ celdas.append(_aviso(pieza))
208
+ lineas.append("| " + " | ".join(_escapar(c) for c in celdas) + " |")
209
+ lineas.append("")
210
+ if platform is Platform.YOUTUBE:
211
+ reach_rows = [(pieza, pieza.especificas.get("thumbnail_reach_28d"))
212
+ for pieza in lectura.piezas]
213
+ reach_rows = [(pieza, reach) for pieza, reach in reach_rows
214
+ if isinstance(reach, dict) and reach.get("impressions") is not None]
215
+ if reach_rows:
216
+ lineas += ["### Thumbnail reach (28 days · YouTube Reporting)", "",
217
+ "| Item | Impressions | CTR | Through | Report |",
218
+ "|---|---:|---:|---|---|"]
219
+ for pieza, reach in sorted(reach_rows,
220
+ key=lambda item: item[1]["impressions"],
221
+ reverse=True)[:10]:
222
+ ctr = reach.get("ctr")
223
+ ctr_text = f"{ctr:.1f}%" if isinstance(ctr, (int, float)) else "—"
224
+ cells = (_recortar(pieza.titulo, LARGO_TITULO),
225
+ str(reach["impressions"]), ctr_text,
226
+ reach.get("through", "—"),
227
+ ", ".join(reach.get("report_ids", [])))
228
+ lineas.append("| " + " | ".join(_escapar(value) for value in cells) + " |")
229
+ lineas.append("")
230
+ else:
231
+ lineas += ["**Thumbnails:** no imported reach report for these items.", ""]
232
+
233
+ lineas += [
234
+ "---",
235
+ "",
236
+ "Generated by `socialcli stats`. Full detail — retention curve,",
237
+ "traffic sources, audience — is in the JSON for the same day.",
238
+ "Thumbnail impressions and CTR come from YouTube Reporting when",
239
+ "an imported reach report exists; missing data does not equal zero.",
240
+ "",
241
+ ]
242
+ return "\n".join(lineas)
243
+
244
+
245
+ def escribir_resumen(brand: Brand, snapshot: Snapshot) -> Path:
246
+ texto = render_resumen(
247
+ snapshot,
248
+ anterior=snapshot_anterior(brand, antes_de=snapshot.fecha),
249
+ piezas=cargar_piezas(brand),
250
+ )
251
+ return escribir_atomico(dir_metricas(brand) / "resumen.md", texto)
@@ -0,0 +1,89 @@
1
+ """Link measured items to publishing posts using resultado.json. Only confirmed publications qualify; unmatched external items retain slug None."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import warnings
8
+
9
+ from socialctl.brands import Brand
10
+ from socialctl.metricas.modelos import Snapshot
11
+ from socialctl.models import Platform, PostStatus
12
+
13
+ _ID_DE_VIDEO_TIKTOK = re.compile(r"/video/(\d+)")
14
+
15
+
16
+ def _id_de_pieza(red: str, entrada: dict) -> str | None:
17
+ """Resolve ID used by metrics reader. TikTok publish_id is not video ID; extract actual ID from shared video URL or return None."""
18
+ if red == Platform.TIKTOK.value:
19
+ encontrado = _ID_DE_VIDEO_TIKTOK.search(entrada.get("url") or "")
20
+ return encontrado.group(1) if encontrado else None
21
+
22
+ id_pieza = entrada.get("platform_id")
23
+ return id_pieza if isinstance(id_pieza, str) and id_pieza else None
24
+
25
+
26
+ def mapa_de_slugs(brand: Brand) -> dict[tuple[str, str], str]:
27
+ """Build (platform, item ID) to slug mapping from post results; silently skip corrupt historical files because linking is optional enrichment."""
28
+ mapa: dict[tuple[str, str], str] = {}
29
+ carpeta = brand.dir_posts
30
+ if not carpeta.is_dir():
31
+ return mapa
32
+
33
+ for fichero in sorted(carpeta.glob("*/resultado.json")):
34
+ try:
35
+ datos = json.loads(fichero.read_text(encoding="utf-8"))
36
+ except (OSError, ValueError):
37
+ continue
38
+ if not isinstance(datos, dict):
39
+ continue
40
+ entradas = datos.get("resultados")
41
+ if not isinstance(entradas, list):
42
+ continue
43
+
44
+ # El slug es el nombre de la CARPETA, no el campo `slug` del JSON:
45
+ # la carpeta es lo que el gestor tiene que abrir después.
46
+ slug = fichero.parent.name
47
+
48
+ for entrada in entradas:
49
+ if not isinstance(entrada, dict):
50
+ continue
51
+ if entrada.get("status") != PostStatus.PUBLICADO.value:
52
+ continue
53
+ red = entrada.get("platform")
54
+ if not isinstance(red, str):
55
+ continue
56
+ id_pieza = _id_de_pieza(red, entrada)
57
+ if id_pieza is None:
58
+ continue
59
+ # Si dos posts reclamaran la misma pieza (un reintento que
60
+ # duplicó, por ejemplo), gana el primero en orden alfabético de
61
+ # carpeta, que es estable entre ejecuciones. Si ocurre, avisa.
62
+ clave = (red, id_pieza)
63
+ anterior = mapa.get(clave)
64
+ if anterior is None:
65
+ mapa[clave] = slug
66
+ else:
67
+ warnings.warn(
68
+ f"resultado.json: two posts claim the same pair "
69
+ f"(red={red!r}, id={id_pieza!r}): post with slug {anterior!r} "
70
+ f"is kept; post with slug {slug!r} is discarded. Review "
71
+ "those two folders in posts/ to determine which is the "
72
+ "actual publication.",
73
+ stacklevel=3,
74
+ )
75
+
76
+ return mapa
77
+
78
+
79
+ def asignar_slugs(brand: Brand, snapshot: Snapshot) -> None:
80
+ """Assign matching publishing post slug to snapshot items without overwriting any supplied slug."""
81
+ mapa = mapa_de_slugs(brand)
82
+ if not mapa:
83
+ return
84
+
85
+ for platform, lectura in snapshot.redes.items():
86
+ for pieza in lectura.piezas:
87
+ if pieza.slug:
88
+ continue
89
+ pieza.slug = mapa.get((platform.value, pieza.id))
@@ -0,0 +1,185 @@
1
+ """Read TikTok Display API metrics using user.info.stats/video.list. Preserve absent fields as None and inspect error bodies before HTTP status; sanitize any reflected token values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, timezone
6
+
7
+ import httpx
8
+
9
+ from socialctl.adapters.errores import mensaje_de_error
10
+ from socialctl.auth import obtener_token
11
+ from socialctl.brands import Brand
12
+ from socialctl.metricas.base import Lector, SinPermiso
13
+ from socialctl.metricas.modelos import (
14
+ Cuenta,
15
+ EstadoLectura,
16
+ LecturaRed,
17
+ Metricas,
18
+ Pieza,
19
+ TipoPieza,
20
+ )
21
+ # Misma clave y mismo mecanismo de señal que YouTube y Meta, importada del
22
+ # mismo sitio en vez de reescrita: ver `CLAVE_ENRIQUECIMIENTO_FALLIDO` en
23
+ # `socialctl/metricas/youtube.py`.
24
+ from socialctl.metricas.youtube import CLAVE_ENRIQUECIMIENTO_FALLIDO
25
+ from socialctl.models import Platform
26
+
27
+ USER_INFO = "https://open.tiktokapis.com/v2/user/info/"
28
+ VIDEO_LIST = "https://open.tiktokapis.com/v2/video/list/"
29
+
30
+ CAMPOS_USUARIO = "open_id,follower_count,likes_count,video_count"
31
+ CAMPOS_VIDEO = (
32
+ "id,title,video_description,create_time,share_url,"
33
+ "view_count,like_count,comment_count,share_count"
34
+ )
35
+
36
+ #: Tope de páginas de `video/list`. Existe para que un `has_more` que nunca
37
+ #: se apague (o un cursor que no avance) no deje el comando girando para
38
+ #: siempre: 20 páginas de 20 son 400 vídeos, muy por encima de cualquier
39
+ #: marca de este proyecto.
40
+ MAX_PAGINAS = 20
41
+
42
+
43
+ class TikTokLector(Lector):
44
+ platform = Platform.TIKTOK
45
+
46
+ def leer(
47
+ self, brand: Brand, client: httpx.Client, desde: date | None
48
+ ) -> LecturaRed:
49
+ token = obtener_token(brand, self.platform, client)
50
+ cabeceras = {"Authorization": f"Bearer {token}"}
51
+
52
+ usuario = self._get(
53
+ client, USER_INFO, cabeceras, {"fields": CAMPOS_USUARIO}, "user.info.stats"
54
+ )["data"].get("user", {})
55
+ cuenta = Cuenta(
56
+ seguidores=usuario.get("follower_count"),
57
+ total_piezas=usuario.get("video_count"),
58
+ total_vistas=None, # TikTok no da vistas totales de cuenta
59
+ )
60
+
61
+ piezas: list[Pieza] = []
62
+ cursor = None
63
+ for numero_de_pagina in range(1, MAX_PAGINAS + 1):
64
+ cuerpo: dict = {"max_count": 20}
65
+ if cursor is not None:
66
+ cuerpo["cursor"] = cursor
67
+ try:
68
+ datos = self._post(
69
+ client, VIDEO_LIST, cabeceras, {"fields": CAMPOS_VIDEO}, cuerpo,
70
+ "video.list",
71
+ )["data"]
72
+ except SinPermiso:
73
+ # Falta un scope: no es una lectura parcial, es una que no
74
+ # puede hacerse. Sube tal cual para que el usuario vea el
75
+ # permiso que falta y el `auth` que lo arregla.
76
+ raise
77
+ except Exception as exc: # noqa: BLE001 - degradar, no perderlo todo
78
+ if not piezas:
79
+ # La primera página falló: no hay nada leído que salvar y
80
+ # esto es un fallo de la red entera, no una lectura
81
+ # parcial. Sube y `leer_red` lo convierte en `error`.
82
+ raise
83
+ # Hay páginas ya leídas: conservarlas es mejor que tirarlas.
84
+ # Antes de este arreglo, un fallo en la página 3 perdía las
85
+ # piezas de la 1 y la 2 -TikTok era el único lector que no
86
+ # degradaba: YouTube aísla el enriquecimiento pieza a pieza y
87
+ # Meta reintenta sin insights-. Se marca cada pieza
88
+ # conservada con `CLAVE_ENRIQUECIMIENTO_FALLIDO`, el mismo
89
+ # mecanismo que usan las otras dos: una clave que solo existe
90
+ # cuando algo falló, para que su sola presencia sea la señal
91
+ # y `resumen.md` la muestre en su columna "Aviso".
92
+ #
93
+ # El texto no puede arrastrar el token: los errores de este
94
+ # lector se construyen con `mensaje_de_error` (ver
95
+ # `_comprobar`), y el token de TikTok viaja en la cabecera
96
+ # `Authorization`, nunca en la URL que lleva un
97
+ # `HTTPStatusError`.
98
+ motivo = (
99
+ f"partial read: page {numero_de_pagina} of "
100
+ f"video/list failed ({exc}); preserving the "
101
+ f"{len(piezas)} items already read; older items are missing"
102
+ )
103
+ for pieza_leida in piezas:
104
+ pieza_leida.especificas[CLAVE_ENRIQUECIMIENTO_FALLIDO] = motivo
105
+ break
106
+
107
+ for video in datos.get("videos", []):
108
+ publicado = datetime.fromtimestamp(
109
+ video["create_time"], tz=timezone.utc
110
+ ).replace(tzinfo=None)
111
+ if desde is not None and publicado.date() < desde:
112
+ continue
113
+ piezas.append(Pieza(
114
+ id=video["id"],
115
+ url=video.get("share_url", ""),
116
+ titulo=(video.get("title") or video.get("video_description") or "")[:120],
117
+ publicado_el=publicado,
118
+ tipo=TipoPieza.VERTICAL, # en TikTok todo es vertical
119
+ acumulado=Metricas(
120
+ vistas=video.get("view_count"),
121
+ likes=video.get("like_count"),
122
+ comentarios=video.get("comment_count"),
123
+ compartidos=video.get("share_count"),
124
+ ),
125
+ ))
126
+
127
+ if not datos.get("has_more"):
128
+ break
129
+ cursor = datos.get("cursor")
130
+
131
+ return LecturaRed(estado=EstadoLectura.OK, account_id=usuario.get('open_id'), cuenta=cuenta, piezas=piezas)
132
+
133
+ # --- peticiones -----------------------------------------------------
134
+
135
+ def _comprobar(self, r: httpx.Response, datos: dict, scope: str, token: str) -> dict:
136
+ error = datos.get("error") or {}
137
+ codigo = error.get("code", "ok")
138
+ if codigo != "ok":
139
+ mensaje = str(error.get("message", ""))
140
+ if "scope" in codigo or "scope" in mensaje.lower():
141
+ raise SinPermiso(self.platform, scope)
142
+ # El texto sale de `mensaje_de_error` y no del `message` crudo:
143
+ # la forma del error de TikTok es la misma `{"error": {"message":
144
+ # ...}}` que ese helper ya entiende, y además redacta el token
145
+ # por si un intermediario reflejó la petición en el cuerpo. Un
146
+ # `error` de este lector acaba en el snapshot, que se versiona.
147
+ raise RuntimeError(
148
+ f"TikTok returned '{codigo}': {mensaje_de_error(r, token)}"
149
+ )
150
+ return datos
151
+
152
+ def _respuesta(self, r: httpx.Response, scope: str, token: str) -> dict:
153
+ """Inspect TikTok JSON error body before HTTP status so missing scopes become actionable permission errors even on 401/403; non-JSON bodies fall back to normal HTTP handling."""
154
+ try:
155
+ datos = r.json()
156
+ except ValueError:
157
+ datos = None
158
+
159
+ if isinstance(datos, dict):
160
+ self._comprobar(r, datos, scope, token)
161
+
162
+ r.raise_for_status()
163
+
164
+ if not isinstance(datos, dict):
165
+ raise RuntimeError(
166
+ f"TikTok returned {r.status_code} with a non-JSON body"
167
+ )
168
+ return datos
169
+
170
+ def _token(self, cabeceras: dict) -> str:
171
+ """Token used in this request, for redaction from errors."""
172
+ return str(cabeceras.get("Authorization", "")).removeprefix("Bearer ")
173
+
174
+ def _get(
175
+ self, client: httpx.Client, url: str, cabeceras: dict, params: dict, scope: str
176
+ ) -> dict:
177
+ r = client.get(url, headers=cabeceras, params=params)
178
+ return self._respuesta(r, scope, self._token(cabeceras))
179
+
180
+ def _post(
181
+ self, client: httpx.Client, url: str, cabeceras: dict,
182
+ params: dict, cuerpo: dict, scope: str,
183
+ ) -> dict:
184
+ r = client.post(url, headers=cabeceras, params=params, json=cuerpo)
185
+ return self._respuesta(r, scope, self._token(cabeceras))