socialcli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,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