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,133 @@
|
|
|
1
|
+
"""Read Instagram Business metrics via Graph API, sharing Facebook token handling, insights parsing and sanitized errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from socialctl.auth import obtener_token
|
|
10
|
+
from socialctl.authflow import GRAFO
|
|
11
|
+
from socialctl.brands import Brand
|
|
12
|
+
from socialctl.metricas.base import Lector
|
|
13
|
+
from socialctl.metricas.facebook import (
|
|
14
|
+
CLAVE_ENRIQUECIMIENTO_FALLIDO,
|
|
15
|
+
POR_PAGINA_META,
|
|
16
|
+
comprobar_respuesta,
|
|
17
|
+
fecha_meta,
|
|
18
|
+
id_de_cuenta,
|
|
19
|
+
pedir_listado_con_insights,
|
|
20
|
+
valor_insight,
|
|
21
|
+
)
|
|
22
|
+
from socialctl.metricas.modelos import (
|
|
23
|
+
Cuenta,
|
|
24
|
+
EstadoLectura,
|
|
25
|
+
LecturaRed,
|
|
26
|
+
Metricas,
|
|
27
|
+
Pieza,
|
|
28
|
+
TipoPieza,
|
|
29
|
+
)
|
|
30
|
+
from socialctl.models import Platform
|
|
31
|
+
|
|
32
|
+
#: Las cinco comprobadas vivas el 2026-09-11 contra @histo.past en la Graph
|
|
33
|
+
#: API v26.0. `views` es la que cuenta reproducciones y aplica a todo tipo de
|
|
34
|
+
#: media, no solo a vídeo; va a `Metricas.vistas`, que es su sitio.
|
|
35
|
+
#:
|
|
36
|
+
#: Retiradas en v26.0, y por eso ausentes de esta lista: `impressions` -el
|
|
37
|
+
#: error lo dice literal: «The Media Insights API does not support the
|
|
38
|
+
#: impressions metric»- y `plays`, sustituida por `views`. A diferencia de
|
|
39
|
+
#: Facebook, aquí `reach` sigue vivo, así que Instagram **sí** conserva su
|
|
40
|
+
#: campo `alcance`.
|
|
41
|
+
METRICAS_MEDIA = "reach,saved,shares,total_interactions,views"
|
|
42
|
+
|
|
43
|
+
#: El resto de `fields` del listado de medias, sin el bloque de insights.
|
|
44
|
+
#: Igual que `CAMPOS_POST` en `socialctl/metricas/facebook.py`: ninguno de
|
|
45
|
+
#: estos depende de una métrica de insights, así que ninguno se pierde
|
|
46
|
+
#: cuando `pedir_listado_con_insights` reintenta sin ese bloque.
|
|
47
|
+
CAMPOS_MEDIA = "id,caption,media_type,timestamp,permalink,like_count,comments_count"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InstagramLector(Lector):
|
|
51
|
+
platform = Platform.INSTAGRAM
|
|
52
|
+
|
|
53
|
+
def leer(
|
|
54
|
+
self, brand: Brand, client: httpx.Client, desde: date | None
|
|
55
|
+
) -> LecturaRed:
|
|
56
|
+
token = obtener_token(brand, self.platform, client)
|
|
57
|
+
ig_id = id_de_cuenta(brand, "instagram", "ig_user_id")
|
|
58
|
+
|
|
59
|
+
cuenta_cruda = self._pedir(
|
|
60
|
+
client, f"{GRAFO}/{ig_id}",
|
|
61
|
+
{"fields": "followers_count,media_count", "access_token": token},
|
|
62
|
+
)
|
|
63
|
+
cuenta = Cuenta(
|
|
64
|
+
seguidores=cuenta_cruda.get("followers_count"),
|
|
65
|
+
total_piezas=cuenta_cruda.get("media_count"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Mismo mecanismo que Facebook (ver `pedir_listado_con_insights` en
|
|
69
|
+
# `socialctl/metricas/facebook.py`): si Meta retiró el nombre de
|
|
70
|
+
# alguna métrica pedida, la petición del listado responde 400 y se
|
|
71
|
+
# reintenta sin el bloque de insights en vez de perder el listado
|
|
72
|
+
# entero. `motivo_degradacion` es `None` salvo que haya hecho falta
|
|
73
|
+
# ese reintento.
|
|
74
|
+
medias, motivo_degradacion = pedir_listado_con_insights(
|
|
75
|
+
client, f"{GRAFO}/{ig_id}/media", CAMPOS_MEDIA, METRICAS_MEDIA,
|
|
76
|
+
{"limit": POR_PAGINA_META}, self.platform, "instagram_manage_insights", token,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
piezas = []
|
|
80
|
+
for media in medias.get("data", []):
|
|
81
|
+
publicado = fecha_meta(media["timestamp"])
|
|
82
|
+
if desde is not None and publicado.date() < desde:
|
|
83
|
+
continue
|
|
84
|
+
insights = media.get("insights", {})
|
|
85
|
+
pieza = Pieza(
|
|
86
|
+
id=media["id"],
|
|
87
|
+
url=media.get("permalink", ""),
|
|
88
|
+
titulo=(media.get("caption") or "")[:120],
|
|
89
|
+
publicado_el=publicado,
|
|
90
|
+
tipo=(
|
|
91
|
+
# `media_type` solo vale IMAGE, VIDEO o CAROUSEL_ALBUM
|
|
92
|
+
# (documentado). Que un reel llegue con
|
|
93
|
+
# `media_type: "VIDEO"` es, en cambio, una SUPOSICIÓN
|
|
94
|
+
# heredada del plan original: no está entre los datos
|
|
95
|
+
# verificados el 2026-09-11 contra @histo.past -esa
|
|
96
|
+
# cuenta no tenía ningún reel que comprobar-, así que no
|
|
97
|
+
# se afirma como dato comprobado. Si aparece uno con otro
|
|
98
|
+
# `media_type`, esta es la primera línea sospechosa.
|
|
99
|
+
TipoPieza.VERTICAL
|
|
100
|
+
if media.get("media_type") == "VIDEO"
|
|
101
|
+
else TipoPieza.IMAGEN
|
|
102
|
+
),
|
|
103
|
+
acumulado=Metricas(
|
|
104
|
+
vistas=valor_insight(insights, "views"),
|
|
105
|
+
likes=media.get("like_count"),
|
|
106
|
+
comentarios=media.get("comments_count"),
|
|
107
|
+
guardados=valor_insight(insights, "saved"),
|
|
108
|
+
compartidos=valor_insight(insights, "shares"),
|
|
109
|
+
),
|
|
110
|
+
especificas={
|
|
111
|
+
"alcance": valor_insight(insights, "reach"),
|
|
112
|
+
"interacciones_totales": valor_insight(insights, "total_interactions"),
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
if motivo_degradacion is not None:
|
|
116
|
+
# Señal de lectura parcial, igual que en Facebook: ver
|
|
117
|
+
# `CLAVE_ENRIQUECIMIENTO_FALLIDO` en
|
|
118
|
+
# `socialctl/metricas/youtube.py`.
|
|
119
|
+
pieza.especificas[CLAVE_ENRIQUECIMIENTO_FALLIDO] = motivo_degradacion
|
|
120
|
+
piezas.append(pieza)
|
|
121
|
+
|
|
122
|
+
return LecturaRed(estado=EstadoLectura.OK, account_id=ig_id, cuenta=cuenta, piezas=piezas)
|
|
123
|
+
|
|
124
|
+
def _pedir(self, client: httpx.Client, url: str, params: dict) -> dict:
|
|
125
|
+
# Sin `raise_for_status()`, por lo mismo que en Facebook: el token
|
|
126
|
+
# viaja en la URL y el mensaje de esa excepción la lleva entera.
|
|
127
|
+
r = client.get(url, params=params)
|
|
128
|
+
return comprobar_respuesta(
|
|
129
|
+
r,
|
|
130
|
+
self.platform,
|
|
131
|
+
"instagram_manage_insights",
|
|
132
|
+
str(params.get("access_token") or ""),
|
|
133
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Common metrics across four platforms. Missing provider fields are None, never zero; zero is an observed value."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from socialctl.models import Platform
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EstadoLectura(str, Enum):
|
|
15
|
+
"""Outcome of a platform metrics read."""
|
|
16
|
+
|
|
17
|
+
OK = "ok"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
SIN_CREDENCIALES = "sin_credenciales"
|
|
20
|
+
SIN_PERMISO = "sin_permiso"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TipoPieza(str, Enum):
|
|
24
|
+
LARGO = "largo"
|
|
25
|
+
VERTICAL = "vertical"
|
|
26
|
+
IMAGEN = "imagen"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Metricas(BaseModel):
|
|
30
|
+
vistas: int | None = None
|
|
31
|
+
likes: int | None = None
|
|
32
|
+
comentarios: int | None = None
|
|
33
|
+
compartidos: int | None = None
|
|
34
|
+
guardados: int | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Cuenta(BaseModel):
|
|
38
|
+
seguidores: int | None = None
|
|
39
|
+
total_piezas: int | None = None
|
|
40
|
+
total_vistas: int | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Audiencia(BaseModel):
|
|
44
|
+
"""Channel audience breakdown; currently supplied only by YouTube."""
|
|
45
|
+
|
|
46
|
+
paises: dict[str, float] = Field(default_factory=dict)
|
|
47
|
+
edades: dict[str, float] = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Pieza(BaseModel):
|
|
51
|
+
id: str
|
|
52
|
+
url: str
|
|
53
|
+
titulo: str
|
|
54
|
+
publicado_el: datetime
|
|
55
|
+
tipo: TipoPieza
|
|
56
|
+
slug: str | None = None
|
|
57
|
+
duracion_seg: int | None = None
|
|
58
|
+
"""Actual technical media duration in seconds when provider supplies it; editorial fallback may be entered only when absent."""
|
|
59
|
+
acumulado: Metricas = Field(default_factory=Metricas)
|
|
60
|
+
# `ultimos_28_dias` vivía aquí y se ha quitado: ningún lector lo
|
|
61
|
+
# rellenaba, nadie lo leía (ni `resumen.md`, ni `piezas.yml`, ni el
|
|
62
|
+
# skill) y salía como `null` en cada pieza de cada snapshot versionado,
|
|
63
|
+
# sugiriendo un dato que no existe. Un campo que nadie escribe no
|
|
64
|
+
# documenta un plan, documenta una promesa incumplida. Los snapshots
|
|
65
|
+
# antiguos que lo llevan se siguen leyendo sin problema: pydantic ignora
|
|
66
|
+
# las claves que el modelo ya no declara. Si algún día se quiere de
|
|
67
|
+
# verdad -la ventana de 28 días que da la API de YouTube-, vuelve con el
|
|
68
|
+
# lector que lo rellene, no antes.
|
|
69
|
+
especificas: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LecturaRed(BaseModel):
|
|
73
|
+
estado: EstadoLectura
|
|
74
|
+
account_id: str | None = None
|
|
75
|
+
failure_code: str | None = None
|
|
76
|
+
error: str | None = None
|
|
77
|
+
warnings: list[str] = Field(default_factory=list)
|
|
78
|
+
observed_at: datetime | None = None
|
|
79
|
+
cuenta: Cuenta | None = None
|
|
80
|
+
piezas: list[Pieza] = Field(default_factory=list)
|
|
81
|
+
audiencia: Audiencia | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Snapshot(BaseModel):
|
|
85
|
+
fecha: date
|
|
86
|
+
marca: str
|
|
87
|
+
redes: dict[Platform, LecturaRed] = Field(default_factory=dict)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Preserve editorial attributes in <Brand>/metricas/piezas.yml while refreshing technical API/post data. Unknown user keys survive; malformed or duplicate entries warn and degrade gracefully."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from socialctl.brands import Brand
|
|
11
|
+
from socialctl.metricas.almacen import dir_metricas, escribir_atomico
|
|
12
|
+
from socialctl.metricas.modelos import Snapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PiezasIlegibles(Exception):
|
|
16
|
+
"""Existing piezas.yml cannot be read and must not be overwritten, since editorial data cannot be regenerated from APIs."""
|
|
17
|
+
|
|
18
|
+
CAMPOS_EDITORIALES = ("episodio", "pilar", "gancho", "notas")
|
|
19
|
+
"""Editorial fields are initialized empty by stats and never overwritten. Duration is technical and refreshed only when provider supplies it."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ruta_piezas(brand: Brand) -> Path:
|
|
23
|
+
return dir_metricas(brand) / "piezas.yml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cargar_piezas(brand: Brand) -> list[dict]:
|
|
27
|
+
"""Load piezas.yml or return an empty list if absent; reject unreadable or non-list YAML to preserve editorial data."""
|
|
28
|
+
ruta = ruta_piezas(brand)
|
|
29
|
+
if not ruta.is_file():
|
|
30
|
+
return []
|
|
31
|
+
texto = ruta.read_text(encoding="utf-8")
|
|
32
|
+
try:
|
|
33
|
+
datos = yaml.safe_load(texto)
|
|
34
|
+
except yaml.YAMLError as exc:
|
|
35
|
+
raise PiezasIlegibles(
|
|
36
|
+
f"{ruta} is not valid YAML ({exc.__class__.__name__}); it is not "
|
|
37
|
+
"modified to avoid deleting editorial data. Repair it manually "
|
|
38
|
+
"—or move it aside to start over—and "
|
|
39
|
+
"run stats."
|
|
40
|
+
) from exc
|
|
41
|
+
if datos is None and not texto.strip():
|
|
42
|
+
# Un fichero vacío del todo: no hay nada escrito que perder.
|
|
43
|
+
return []
|
|
44
|
+
if not isinstance(datos, list):
|
|
45
|
+
raise PiezasIlegibles(
|
|
46
|
+
f"{ruta} should be a list of items but is "
|
|
47
|
+
f"{type(datos).__name__}; left unchanged to avoid deleting editorial data "
|
|
48
|
+
"it contains. Repair it manually and rerun stats."
|
|
49
|
+
)
|
|
50
|
+
return datos
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fusionar(existentes: list[dict], snapshot: Snapshot) -> list[dict]:
|
|
54
|
+
"""Pure merge of provider data and existing editorial entries. Discard malformed entries with warnings; later duplicate identities win, with warnings."""
|
|
55
|
+
por_clave: dict[tuple, dict] = {}
|
|
56
|
+
for posicion, entrada_cruda in enumerate(existentes):
|
|
57
|
+
if not isinstance(entrada_cruda, dict):
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f"piezas.yml: entry number {posicion + 1} is not a "
|
|
60
|
+
f"dictionary (type {type(entrada_cruda).__name__}: "
|
|
61
|
+
f"{entrada_cruda!r}); ignored. Repair it manually in "
|
|
62
|
+
"file and rerun stats.",
|
|
63
|
+
stacklevel=2,
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
clave = (entrada_cruda.get("red"), entrada_cruda.get("id"))
|
|
68
|
+
anterior = por_clave.get(clave)
|
|
69
|
+
if anterior is not None:
|
|
70
|
+
identidad = (
|
|
71
|
+
"without 'red' or 'id'"
|
|
72
|
+
if clave == (None, None)
|
|
73
|
+
else f"red={clave[0]!r}, id={clave[1]!r}"
|
|
74
|
+
)
|
|
75
|
+
warnings.warn(
|
|
76
|
+
f"piezas.yml: two entries share the same identity "
|
|
77
|
+
f"({identidad}): item titled {anterior.get('titulo')!r} is "
|
|
78
|
+
f"discarded in favor of item titled "
|
|
79
|
+
f"{entrada_cruda.get('titulo')!r}. Assign distinct 'red' and 'id' values "
|
|
80
|
+
"in the file to distinguish them.",
|
|
81
|
+
stacklevel=2,
|
|
82
|
+
)
|
|
83
|
+
por_clave[clave] = dict(entrada_cruda)
|
|
84
|
+
|
|
85
|
+
for platform, lectura in snapshot.redes.items():
|
|
86
|
+
for pieza in lectura.piezas:
|
|
87
|
+
clave = (platform.value, pieza.id)
|
|
88
|
+
entrada = por_clave.get(clave, {campo: None for campo in CAMPOS_EDITORIALES})
|
|
89
|
+
entrada.update({
|
|
90
|
+
"red": platform.value,
|
|
91
|
+
"id": pieza.id,
|
|
92
|
+
"tipo": pieza.tipo.value,
|
|
93
|
+
"publicado_el": pieza.publicado_el.isoformat(),
|
|
94
|
+
"titulo": pieza.titulo,
|
|
95
|
+
**({"api_observed_at": (lectura.observed_at.isoformat() if lectura.observed_at else snapshot.fecha.isoformat())} if platform.value == "youtube" else {}),
|
|
96
|
+
})
|
|
97
|
+
if pieza.slug is not None:
|
|
98
|
+
# El cruce con `posts/` lo resolvió: manda el cruce.
|
|
99
|
+
entrada["slug"] = pieza.slug
|
|
100
|
+
else:
|
|
101
|
+
# No se resolvió: una pieza publicada fuera de `socialctl`, o
|
|
102
|
+
# un TikTok cuyo `resultado.json` no guarda la URL, que es el
|
|
103
|
+
# caso habitual. Se conserva lo que hubiera, porque puede
|
|
104
|
+
# haberlo escrito el gestor a mano. Misma regla asimétrica
|
|
105
|
+
# que `duracion_seg`, y por el mismo motivo: lo que esta
|
|
106
|
+
# ejecución no trae, no se toca.
|
|
107
|
+
entrada.setdefault("slug", None)
|
|
108
|
+
if pieza.duracion_seg is not None:
|
|
109
|
+
# La red la da: manda la red.
|
|
110
|
+
entrada["duracion_seg"] = pieza.duracion_seg
|
|
111
|
+
else:
|
|
112
|
+
# La red no la da (TikTok, Instagram): se conserva lo que
|
|
113
|
+
# hubiera, que puede haberlo escrito el gestor a mano.
|
|
114
|
+
entrada.setdefault("duracion_seg", None)
|
|
115
|
+
for campo in CAMPOS_EDITORIALES:
|
|
116
|
+
entrada.setdefault(campo, None)
|
|
117
|
+
por_clave[clave] = entrada
|
|
118
|
+
|
|
119
|
+
return sorted(
|
|
120
|
+
por_clave.values(),
|
|
121
|
+
key=lambda e: (e.get("publicado_el") or "", e.get("red") or ""),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def guardar_piezas(brand: Brand, piezas: list[dict]) -> Path:
|
|
126
|
+
"""Atomically rewrite piezas.yml with supplied entries. YAML comments are lost; preserve annotations in notas and retain all user-defined keys."""
|
|
127
|
+
return escribir_atomico(
|
|
128
|
+
ruta_piezas(brand),
|
|
129
|
+
yaml.safe_dump(piezas, allow_unicode=True, sort_keys=False),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def actualizar_piezas(brand: Brand, snapshot: Snapshot) -> Path:
|
|
134
|
+
return guardar_piezas(brand, fusionar(cargar_piezas(brand), snapshot))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Select and aggregate imported YouTube Reporting thumbnail reach observations."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, timedelta, timezone
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
REPORT_TYPES = {'channel_reach_basic_a1', 'channel_reach_combined_a1'}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _day(value: str) -> date:
|
|
10
|
+
if len(value) != 8 or not value.isdigit():
|
|
11
|
+
raise ValueError('Reporting date must be YYYYMMDD')
|
|
12
|
+
return date.fromisoformat(f'{value[:4]}-{value[4:6]}-{value[6:]}')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _instant(value: str) -> datetime:
|
|
16
|
+
when = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
17
|
+
if when.utcoffset() is None:
|
|
18
|
+
raise ValueError('Report creation time needs timezone')
|
|
19
|
+
return when.astimezone(timezone.utc)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reach_by_video_day(observations: list[dict]) -> dict[tuple[str, date], dict]:
|
|
23
|
+
"""Prefer the newest report for overlapping video/day keys."""
|
|
24
|
+
selected = {}
|
|
25
|
+
for observation in observations:
|
|
26
|
+
binding = observation.get('binding', {})
|
|
27
|
+
report_type = binding.get('report_type_id')
|
|
28
|
+
if binding.get('source') != 'youtubeReporting' or report_type not in REPORT_TYPES:
|
|
29
|
+
continue
|
|
30
|
+
report = binding.get('report', {})
|
|
31
|
+
created = _instant(report['createTime'])
|
|
32
|
+
report_id = report['id']
|
|
33
|
+
priority = (created, int(report_type == 'channel_reach_combined_a1'), report_id)
|
|
34
|
+
for row in observation.get('rows', []):
|
|
35
|
+
dimensions = row.get('dimensions', {})
|
|
36
|
+
video_id = dimensions.get('video_id')
|
|
37
|
+
if not isinstance(video_id, str) or not video_id:
|
|
38
|
+
continue
|
|
39
|
+
day = _day(dimensions['date'])
|
|
40
|
+
metrics = row.get('metrics', {})
|
|
41
|
+
impressions = metrics.get('video_thumbnail_impressions', {}).get('value')
|
|
42
|
+
ctr = metrics.get('video_thumbnail_impressions_ctr', {}).get('value')
|
|
43
|
+
if (type(impressions) is not int or impressions < 0 or
|
|
44
|
+
ctr is not None and (not isinstance(ctr, (int, float)) or
|
|
45
|
+
not math.isfinite(ctr) or not 0 <= ctr <= 100)):
|
|
46
|
+
continue
|
|
47
|
+
key = (video_id, day)
|
|
48
|
+
if key not in selected or priority > selected[key]['priority']:
|
|
49
|
+
selected[key] = {
|
|
50
|
+
'impressions': impressions,
|
|
51
|
+
'ctr': float(ctr) if ctr is not None else None,
|
|
52
|
+
'report_id': report_id,
|
|
53
|
+
'report_type': report_type,
|
|
54
|
+
'generated_at': created.isoformat(),
|
|
55
|
+
'priority': priority,
|
|
56
|
+
}
|
|
57
|
+
for item in selected.values():
|
|
58
|
+
item.pop('priority')
|
|
59
|
+
return selected
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def reach_for_video(rows: dict[tuple[str, date], dict], video_id: str,
|
|
63
|
+
as_of: date, *, days: int = 28) -> dict | None:
|
|
64
|
+
"""Return count and impression-weighted CTR; absence is None, never zero."""
|
|
65
|
+
if days < 1:
|
|
66
|
+
raise ValueError('Positive reporting window required')
|
|
67
|
+
start = as_of - timedelta(days=days - 1)
|
|
68
|
+
matched = sorted((day, value) for (vid, day), value in rows.items()
|
|
69
|
+
if vid == video_id and start <= day <= as_of)
|
|
70
|
+
if not matched:
|
|
71
|
+
return None
|
|
72
|
+
impressions = sum(value['impressions'] for _, value in matched)
|
|
73
|
+
ctr_denominator = sum(value['impressions'] for _, value in matched
|
|
74
|
+
if value['ctr'] is not None)
|
|
75
|
+
ctr = (round(sum(value['impressions'] * value['ctr'] for _, value in matched
|
|
76
|
+
if value['ctr'] is not None) / ctr_denominator, 2)
|
|
77
|
+
if ctr_denominator else None)
|
|
78
|
+
return {
|
|
79
|
+
'impressions': impressions,
|
|
80
|
+
'ctr': ctr,
|
|
81
|
+
'from': start.isoformat(),
|
|
82
|
+
'through': matched[-1][0].isoformat(),
|
|
83
|
+
'report_ids': list(dict.fromkeys(value['report_id'] for _, value in matched)),
|
|
84
|
+
'source': 'youtubeReporting',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def attach_reach(pieces, observations: list[dict], as_of: date) -> None:
|
|
89
|
+
"""Enrich matching YouTube pieces with a bounded Reporting observation."""
|
|
90
|
+
rows = reach_by_video_day(observations)
|
|
91
|
+
for piece in pieces:
|
|
92
|
+
value = reach_for_video(rows, piece.id, as_of)
|
|
93
|
+
if value is not None:
|
|
94
|
+
piece.especificas['thumbnail_reach_28d'] = value
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Reporting jobs/reports identity chain and explicit bounded CSV downloads."""
|
|
2
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlsplit, unquote
|
|
5
|
+
|
|
6
|
+
from socialctl.management.youtube_resources import ResourceError
|
|
7
|
+
from socialctl.metricas.analytics_client import AnalyticsClient, REPORTING, complete, encoded, opaque
|
|
8
|
+
from socialctl.metricas.analytics_schema import PACIFIC
|
|
9
|
+
|
|
10
|
+
MAX_CSV = 16 * 1024 * 1024
|
|
11
|
+
ANALYTICS_SCOPES = {"https://www.googleapis.com/auth/yt-analytics.readonly", "https://www.googleapis.com/auth/yt-analytics-monetary.readonly"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def instant(value):
|
|
15
|
+
try:
|
|
16
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
17
|
+
if parsed.tzinfo is None:
|
|
18
|
+
raise ValueError()
|
|
19
|
+
return parsed.astimezone(timezone.utc)
|
|
20
|
+
except (ValueError, TypeError, AttributeError):
|
|
21
|
+
raise ResourceError("invalid provider timestamp") from None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def report_period(report):
|
|
25
|
+
start, end = instant(report.get("startTime")), instant(report.get("endTime"))
|
|
26
|
+
local_start, local_end = start.astimezone(PACIFIC), end.astimezone(PACIFIC)
|
|
27
|
+
if end <= start or local_start.time() != time.min or local_end.time() != time.min or (end-start).days > 366:
|
|
28
|
+
raise ResourceError("report period must be bounded Pacific midnight intervals")
|
|
29
|
+
return local_start.date(), local_end.date() - timedelta(days=1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_download_url(value):
|
|
33
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 1000 or not value.isascii() or any(ord(c) <= 32 for c in value):
|
|
34
|
+
raise ResourceError("invalid report download URL")
|
|
35
|
+
parsed = urlsplit(value)
|
|
36
|
+
if parsed.scheme != "https" or parsed.netloc != "youtubereporting.googleapis.com" or parsed.fragment or parsed.query != "alt=media":
|
|
37
|
+
raise ResourceError("report download host/query not allowed")
|
|
38
|
+
if not parsed.path.startswith("/v1/media/") or not re.fullmatch(r"/[A-Za-z0-9._~%/-]+", parsed.path):
|
|
39
|
+
raise ResourceError("report download path not allowed")
|
|
40
|
+
segments = parsed.path[len("/v1/media/"):].split("/")
|
|
41
|
+
if any(not s or unquote(s) in {".", ".."} or any(c in unquote(s) for c in "/\\%?#") for s in segments):
|
|
42
|
+
raise ResourceError("noncanonical report download path")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ReportingClient(AnalyticsClient):
|
|
47
|
+
def report_types(self, max_pages=100):
|
|
48
|
+
self.identity()
|
|
49
|
+
return self.pages(f"{REPORTING}/reportTypes", "reportTypes", {"pageSize": 100, "includeSystemManaged": "true"}, max_pages)
|
|
50
|
+
|
|
51
|
+
def jobs(self, max_pages=100):
|
|
52
|
+
self.identity()
|
|
53
|
+
result = self.pages(f"{REPORTING}/jobs", "jobs", {"pageSize": 100, "includeSystemManaged": "true"}, max_pages)
|
|
54
|
+
for row in result["items"]:
|
|
55
|
+
opaque(row.get("reportTypeId"))
|
|
56
|
+
if not isinstance(row.get("name"), str):
|
|
57
|
+
raise ResourceError("job schema invalid")
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def job(self, job_id, report_type_id=None):
|
|
61
|
+
rows = [r for r in complete(self.jobs()) if r["id"] == opaque(job_id)]
|
|
62
|
+
if len(rows) != 1:
|
|
63
|
+
raise ResourceError("job not verified for authenticated account")
|
|
64
|
+
row = self._json(self.service_request("GET", f"{REPORTING}/jobs/{encoded(job_id)}"))
|
|
65
|
+
if row.get("id") != job_id or row.get("reportTypeId") != rows[0]["reportTypeId"] or report_type_id is not None and row["reportTypeId"] != report_type_id:
|
|
66
|
+
raise ResourceError("job/report type identity mismatch")
|
|
67
|
+
return row
|
|
68
|
+
|
|
69
|
+
def reports(self, job_id, report_type_id, max_pages=100, created_after=None, start_time_at_or_after=None, start_time_before=None):
|
|
70
|
+
self.job(job_id, report_type_id)
|
|
71
|
+
params = {"pageSize": 100}
|
|
72
|
+
for key, value in {"createdAfter": created_after, "startTimeAtOrAfter": start_time_at_or_after, "startTimeBefore": start_time_before}.items():
|
|
73
|
+
if value is not None:
|
|
74
|
+
instant(value)
|
|
75
|
+
params[key] = value
|
|
76
|
+
result = self.pages(f"{REPORTING}/jobs/{encoded(job_id)}/reports", "reports", params, max_pages)
|
|
77
|
+
for row in result["items"]:
|
|
78
|
+
self._validate_report(row, job_id)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _validate_report(row, job_id, report_id=None):
|
|
83
|
+
if row.get("jobId") != job_id or report_id is not None and row.get("id") != report_id:
|
|
84
|
+
raise ResourceError("report/job identity mismatch")
|
|
85
|
+
opaque(row.get("id"))
|
|
86
|
+
report_period(row)
|
|
87
|
+
instant(row.get("createTime"))
|
|
88
|
+
validate_download_url(row.get("downloadUrl"))
|
|
89
|
+
return row
|
|
90
|
+
|
|
91
|
+
def report(self, job_id, report_type_id, report_id):
|
|
92
|
+
self.job(job_id, report_type_id)
|
|
93
|
+
row = self._json(self.service_request("GET", f"{REPORTING}/jobs/{encoded(job_id)}/reports/{encoded(report_id)}"))
|
|
94
|
+
return self._validate_report(row, job_id, report_id)
|
|
95
|
+
|
|
96
|
+
def download(self, *, job_id, report_type_id, report_id, start_date, end_date):
|
|
97
|
+
account = self.identity()
|
|
98
|
+
types = complete(self.report_types())
|
|
99
|
+
if not any(r["id"] == report_type_id for r in types):
|
|
100
|
+
raise ResourceError("report type unavailable for authenticated account")
|
|
101
|
+
row = self.report(job_id, report_type_id, report_id)
|
|
102
|
+
start, end = report_period(row)
|
|
103
|
+
if (start.isoformat(), end.isoformat()) != (start_date, end_date):
|
|
104
|
+
raise ResourceError("explicit report dates mismatch")
|
|
105
|
+
url = validate_download_url(row["downloadUrl"])
|
|
106
|
+
# Only this method may call the download transport, after exact discovery
|
|
107
|
+
# and account/job/type/report/date binding. No bearer follows redirects.
|
|
108
|
+
data = self._bounded_request("GET", url, max_bytes=MAX_CSV).content
|
|
109
|
+
if not data:
|
|
110
|
+
raise ResourceError("empty download; header-only CSV is valid, zero bytes is not")
|
|
111
|
+
return {"source": "youtubeReporting", "account_id": account, "report_type_id": report_type_id,
|
|
112
|
+
"job_id": job_id, "report": row, "credential_observation": self.credential_observation}, data
|