socialcli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of socialcli might be problematic. Click here for more details.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- socialctl/youtube_data_service.py +225 -0
|
@@ -0,0 +1,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))
|