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