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,180 @@
|
|
|
1
|
+
'Publish creator-owned content to a Facebook Page through Graph API v26.0.\n\nThe content type selects the feed, photo or video endpoint. Missing account\nconfiguration and expected I/O/network failures produce actionable results.\nMalformed responses after acceptance carry duplicate risk: check the remote\naccount before retrying. Never interpolate request headers or credentials into\nerrors; known secrets are redacted from provider response text.'
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from socialctl.adapters.base import ADAPTADORES, Adapter
|
|
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.formatter import PLATFORM_SPECS, componer_caption
|
|
14
|
+
from socialctl.models import (
|
|
15
|
+
MediaAsset,
|
|
16
|
+
MediaKind,
|
|
17
|
+
Platform,
|
|
18
|
+
PlatformPost,
|
|
19
|
+
PostResult,
|
|
20
|
+
PostStatus,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
GRAFO = "https://graph.facebook.com/v26.0"
|
|
25
|
+
# v21.0 (versión anterior) caduca el 21 de enero de 2027 ("API Version
|
|
26
|
+
# Deprecations", developers.facebook.com/docs/graph-api/changelog/version26.0,
|
|
27
|
+
# confirmado con Context7); v26.0 es la vigente (introducida el 29 de julio
|
|
28
|
+
# de 2026, según el mismo changelog). Los endpoints y campos que usa este
|
|
29
|
+
# módulo (POST /{page_id}/feed con message, POST /{page_id}/photos con
|
|
30
|
+
# caption+source, POST /{page_id}/videos con description+source) siguen
|
|
31
|
+
# documentados igual en v26.0 (confirmado contra los ejemplos vigentes de
|
|
32
|
+
# video-uploads y event/videos, que ya usan v26.0 en su propia URL de
|
|
33
|
+
# ejemplo): no hay cambios de forma, solo de número de versión en la URL.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FacebookAdapter(Adapter):
|
|
37
|
+
'Publish to a Facebook Page using the endpoint matching the content type.'
|
|
38
|
+
|
|
39
|
+
platform = Platform.FACEBOOK
|
|
40
|
+
|
|
41
|
+
def validate(self, post: PlatformPost, brand: Brand) -> list[ValidationError]:
|
|
42
|
+
'Add the required Page ID check to platform validation.'
|
|
43
|
+
errores = super().validate(post, brand)
|
|
44
|
+
page_id = (brand.cuentas.get("facebook") or {}).get("page_id")
|
|
45
|
+
if not page_id:
|
|
46
|
+
errores.append(
|
|
47
|
+
ValidationError(
|
|
48
|
+
platform=self.platform,
|
|
49
|
+
campo="cuenta",
|
|
50
|
+
motivo=f"missing page_id in {brand.raiz / 'accounts.yml'}",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return errores
|
|
54
|
+
|
|
55
|
+
def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
56
|
+
'Publish a validated post; convert every failure to an error PostResult.'
|
|
57
|
+
spec = PLATFORM_SPECS[self.platform]
|
|
58
|
+
if len(post.media) > spec.max_media:
|
|
59
|
+
return self._error(
|
|
60
|
+
f"this endpoint supports at most {spec.max_media} file; "
|
|
61
|
+
'it does not publish carousels'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
return self._publicar(post, brand, client)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
return self._error(
|
|
68
|
+
f"an unexpected Facebook error occurred ({type(exc).__name__})",
|
|
69
|
+
riesgo_duplicado=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
73
|
+
'Execute publication with explicit handling of expected failures.'
|
|
74
|
+
page_id = (brand.cuentas.get("facebook") or {}).get("page_id")
|
|
75
|
+
if not page_id:
|
|
76
|
+
return self._error(f"missing page_id in {brand.raiz / 'accounts.yml'}")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
token = obtener_token(brand, self.platform, client)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
return self._error(str(exc))
|
|
82
|
+
|
|
83
|
+
texto = componer_caption(post.body, post.hashtags)
|
|
84
|
+
if post.link:
|
|
85
|
+
texto = f"{texto}\n\n{post.link}"
|
|
86
|
+
|
|
87
|
+
asset: MediaAsset | None = post.media[0] if post.media else None
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
if asset is None:
|
|
91
|
+
respuesta = client.post(
|
|
92
|
+
f"{GRAFO}/{page_id}/feed",
|
|
93
|
+
data={"message": texto, "access_token": token},
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
es_video = asset.kind is MediaKind.VIDEO
|
|
97
|
+
endpoint = "videos" if es_video else "photos"
|
|
98
|
+
campo_texto = "description" if es_video else "caption"
|
|
99
|
+
with asset.path.open("rb") as fichero:
|
|
100
|
+
respuesta = client.post(
|
|
101
|
+
f"{GRAFO}/{page_id}/{endpoint}",
|
|
102
|
+
data={campo_texto: texto, "access_token": token},
|
|
103
|
+
files={"source": fichero},
|
|
104
|
+
timeout=None,
|
|
105
|
+
)
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return self._error(f"the file no longer exists on disk: {asset.path}")
|
|
108
|
+
except IsADirectoryError:
|
|
109
|
+
return self._error(
|
|
110
|
+
f"the file path points to a directory, not a file: {asset.path}"
|
|
111
|
+
)
|
|
112
|
+
except PermissionError:
|
|
113
|
+
return self._error(f"the file is not readable: {asset.path}")
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
# Cualquier otro fallo de E/S al leer el fichero (disco dañado,
|
|
116
|
+
# demasiados descriptores abiertos, etc.): no es ninguno de los
|
|
117
|
+
# casos anteriores, pero tampoco debe escapar como excepción.
|
|
118
|
+
return self._error(f"could not read the file from disk ({asset.path}): {exc}")
|
|
119
|
+
except httpx.InvalidURL:
|
|
120
|
+
# La URL se construye con el page_id de accounts.yml: si contiene
|
|
121
|
+
# un carácter no válido (p. ej. un salto de línea colado por un
|
|
122
|
+
# error de copia/pega), httpx la rechaza al construir la petición,
|
|
123
|
+
# antes de llegar a la red.
|
|
124
|
+
return self._error(
|
|
125
|
+
f"the configured page_id ({page_id!r}) produces an invalid URL"
|
|
126
|
+
)
|
|
127
|
+
except httpx.TimeoutException:
|
|
128
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese except para
|
|
129
|
+
# no quedar inalcanzable.
|
|
130
|
+
return self._error(
|
|
131
|
+
'timed out contacting Facebook',
|
|
132
|
+
riesgo_duplicado=True,
|
|
133
|
+
)
|
|
134
|
+
except httpx.HTTPError:
|
|
135
|
+
return self._error(
|
|
136
|
+
'could not connect to Facebook to publish',
|
|
137
|
+
riesgo_duplicado=True,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if respuesta.status_code != 200:
|
|
141
|
+
return self._error(mensaje_de_error(respuesta, token))
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
post_id = respuesta.json()["id"]
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
return self._error(
|
|
147
|
+
'Facebook returned an invalid JSON response after publishing',
|
|
148
|
+
riesgo_duplicado=True,
|
|
149
|
+
)
|
|
150
|
+
except KeyError:
|
|
151
|
+
return self._error(
|
|
152
|
+
'Facebook returned no post ID',
|
|
153
|
+
riesgo_duplicado=True,
|
|
154
|
+
)
|
|
155
|
+
if not isinstance(post_id, str) or not post_id.strip():
|
|
156
|
+
return self._error(
|
|
157
|
+
'Facebook returned an unexpected post ID '
|
|
158
|
+
'(expected a nonempty string)',
|
|
159
|
+
riesgo_duplicado=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# The publisher durably saves this media ID before the independent
|
|
163
|
+
# first-comment step. Adapters must never perform that second mutation.
|
|
164
|
+
return PostResult(
|
|
165
|
+
platform=self.platform,
|
|
166
|
+
status=PostStatus.PUBLICADO,
|
|
167
|
+
platform_id=post_id,
|
|
168
|
+
url=f"https://www.facebook.com/{post_id}",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _error(self, mensaje: str, *, riesgo_duplicado: bool = False) -> PostResult:
|
|
172
|
+
return PostResult(
|
|
173
|
+
platform=self.platform,
|
|
174
|
+
status=PostStatus.ERROR,
|
|
175
|
+
error=mensaje,
|
|
176
|
+
riesgo_duplicado=riesgo_duplicado,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
ADAPTADORES[Platform.FACEBOOK] = FacebookAdapter
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'Publish Instagram media through Graph API v26.0 container creation and delivery.\n\nThe provider fetches user-supplied files from a configured public HTTPS host.\nCreate a container, poll processing, then publish it and read its permalink.\nFailures after a potentially accepted publication report duplicate risk rather\nthan silently retrying. Unexpected IDs are rejected. Known access tokens are\nredacted even when a proxy reflects the failed request in a response.'
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from socialctl.adapters.base import ADAPTADORES, Adapter
|
|
12
|
+
from socialctl.adapters.errores import mensaje_de_error
|
|
13
|
+
from socialctl.auth import obtener_token
|
|
14
|
+
from socialctl.brands import Brand
|
|
15
|
+
from socialctl.formatter import PLATFORM_SPECS, componer_caption
|
|
16
|
+
from socialctl.media import ruta_relativa_efectiva
|
|
17
|
+
from socialctl.models import (
|
|
18
|
+
MediaKind,
|
|
19
|
+
Platform,
|
|
20
|
+
PlatformPost,
|
|
21
|
+
PostResult,
|
|
22
|
+
PostStatus,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
GRAFO = "https://graph.facebook.com/v26.0"
|
|
27
|
+
# v21.0 (versión anterior) caduca el 21 de enero de 2027 ("API Version
|
|
28
|
+
# Deprecations", developers.facebook.com/docs/graph-api/changelog/version26.0,
|
|
29
|
+
# confirmado con Context7); v26.0 es la vigente (introducida el 29 de julio
|
|
30
|
+
# de 2026, según el mismo changelog). Los endpoints y campos que usa este
|
|
31
|
+
# módulo (POST /{ig_user_id}/media, GET /{id}?fields=status_code, POST
|
|
32
|
+
# /{ig_user_id}/media_publish, GET /{id}?fields=permalink) siguen
|
|
33
|
+
# documentados igual en v26.0: no hay cambios de forma, solo de número de
|
|
34
|
+
# versión en la URL.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InstagramAdapter(Adapter):
|
|
38
|
+
'Publish Instagram media using the Graph API container workflow.'
|
|
39
|
+
|
|
40
|
+
platform = Platform.INSTAGRAM
|
|
41
|
+
|
|
42
|
+
def __init__(self, espera_s: float = 60.0, intentos: int = 5) -> None:
|
|
43
|
+
self.espera_s = espera_s
|
|
44
|
+
self.intentos = intentos
|
|
45
|
+
|
|
46
|
+
def validate(self, post: PlatformPost, brand: Brand) -> list[ValidationError]:
|
|
47
|
+
'Add Instagram account and public media hosting checks.'
|
|
48
|
+
errores = super().validate(post, brand)
|
|
49
|
+
cuenta = brand.cuentas.get("instagram") or {}
|
|
50
|
+
|
|
51
|
+
if not cuenta.get("ig_user_id"):
|
|
52
|
+
errores.append(
|
|
53
|
+
ValidationError(
|
|
54
|
+
platform=self.platform,
|
|
55
|
+
campo="cuenta",
|
|
56
|
+
motivo=f"missing ig_user_id in {brand.raiz / 'accounts.yml'}",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
from socialctl.connections.temporary_media import available
|
|
61
|
+
if not cuenta.get("media_url_base") and not available(brand):
|
|
62
|
+
errores.append(
|
|
63
|
+
ValidationError(
|
|
64
|
+
platform=self.platform,
|
|
65
|
+
campo="cuenta",
|
|
66
|
+
motivo=(
|
|
67
|
+
'missing media_url_base in accounts.yml: Instagram requires '
|
|
68
|
+
'a public media URL (direct file upload is '
|
|
69
|
+
'not supported); specify the host serving '
|
|
70
|
+
"your files, for example 'https://cdn.example.com'"
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return errores
|
|
76
|
+
|
|
77
|
+
def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
78
|
+
'Publish a validated post; convert every failure to an error PostResult.'
|
|
79
|
+
spec = PLATFORM_SPECS[self.platform]
|
|
80
|
+
if len(post.media) > spec.max_media:
|
|
81
|
+
return self._error(
|
|
82
|
+
f"this endpoint supports at most {spec.max_media} file; "
|
|
83
|
+
'it does not publish carousels'
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
from socialctl.connections.temporary_media import available,upload
|
|
88
|
+
if not (brand.cuentas.get('instagram') or {}).get('media_url_base') and available(brand) and post.media:
|
|
89
|
+
with upload(brand,post.media[0].path,client) as url:
|
|
90
|
+
return self._publicar(post,brand,client,media_url=url)
|
|
91
|
+
return self._publicar(post, brand, client)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
return self._error(
|
|
94
|
+
f"an unexpected Instagram error occurred ({type(exc).__name__})"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client, *, media_url=None) -> PostResult:
|
|
98
|
+
'Execute publication with explicit handling of expected failures.'
|
|
99
|
+
cuenta = brand.cuentas.get("instagram") or {}
|
|
100
|
+
|
|
101
|
+
ig_user_id = cuenta.get("ig_user_id")
|
|
102
|
+
if not ig_user_id:
|
|
103
|
+
return self._error(f"missing ig_user_id in {brand.raiz / 'accounts.yml'}")
|
|
104
|
+
|
|
105
|
+
base = cuenta.get("media_url_base")
|
|
106
|
+
if not base and not media_url:
|
|
107
|
+
return self._error(
|
|
108
|
+
'missing media_url_base in accounts.yml: Instagram requires a '
|
|
109
|
+
'public media URL (it does not support direct '
|
|
110
|
+
'file upload); specify the host serving your files, '
|
|
111
|
+
"for example 'https://cdn.example.com'"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not post.media:
|
|
115
|
+
return self._error(
|
|
116
|
+
'no image or video to publish: the post has no media attached'
|
|
117
|
+
)
|
|
118
|
+
asset = post.media[0]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
token = obtener_token(brand, self.platform, client)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
return self._error(str(exc))
|
|
124
|
+
|
|
125
|
+
# `ruta_relativa_efectiva` conserva la subcarpeta del archivo (p.
|
|
126
|
+
# ej. "short/S2.mp4", si el usuario guarda sus verticales en
|
|
127
|
+
# <Marca>/media/short/). Se separa por '/' y se codifica cada
|
|
128
|
+
# SEGMENTO por su cuenta con `quote` (espacios, acentos, 'ñ'...);
|
|
129
|
+
# las '/' que separan subcarpetas se reintroducen después de
|
|
130
|
+
# codificar, así que nunca pasan por `quote` y no pueden acabar
|
|
131
|
+
# codificadas como '%2F' -que convertiría "short/S2.mp4" en una URL
|
|
132
|
+
# de un solo segmento que el host del usuario no serviría igual
|
|
133
|
+
# que la carpeta real-.
|
|
134
|
+
segmentos_media = ruta_relativa_efectiva(asset).split("/")
|
|
135
|
+
ruta_codificada = "/".join(quote(segmento) for segmento in segmentos_media)
|
|
136
|
+
url_media = media_url or f"{base.rstrip('/')}/{ruta_codificada}"
|
|
137
|
+
|
|
138
|
+
# Instagram descarga la media desde esta URL en un proceso asíncrono
|
|
139
|
+
# (el sondeo del paso 2): si el archivo aún no está subido al host,
|
|
140
|
+
# el fallo llega minutos después y de forma opaca. Comprobarlo aquí
|
|
141
|
+
# con un HEAD lo convierte en un error inmediato y accionable.
|
|
142
|
+
try:
|
|
143
|
+
sonda = client.head(url_media, follow_redirects=True, timeout=15)
|
|
144
|
+
except httpx.InvalidURL:
|
|
145
|
+
# La URL se construye con media_url_base (de accounts.yml) más
|
|
146
|
+
# el nombre del fichero: si contiene un carácter no válido (p.
|
|
147
|
+
# ej. un salto de línea colado por un error de copia/pega),
|
|
148
|
+
# httpx la rechaza al construir la petición, antes de tocar la
|
|
149
|
+
# red. Esta excepción NO hereda de httpx.HTTPError.
|
|
150
|
+
return self._error(
|
|
151
|
+
f"media_url_base produces an invalid media URL: {url_media!r}"
|
|
152
|
+
)
|
|
153
|
+
except httpx.TimeoutException:
|
|
154
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese except para
|
|
155
|
+
# no quedar inalcanzable.
|
|
156
|
+
return self._error(
|
|
157
|
+
f"timed out checking whether {url_media} "
|
|
158
|
+
'is accessible'
|
|
159
|
+
)
|
|
160
|
+
except httpx.HTTPError:
|
|
161
|
+
return self._error(
|
|
162
|
+
f"could not check whether {url_media} is accessible: check "
|
|
163
|
+
'that the media_url_base host is running'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if sonda.status_code != 200:
|
|
167
|
+
return self._error(
|
|
168
|
+
f"{url_media} did not return HTTP 200 (returned "
|
|
169
|
+
f"{sonda.status_code}); upload the file to that host before "
|
|
170
|
+
'publishing to Instagram'
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
campo_url = "video_url" if asset.kind is MediaKind.VIDEO else "image_url"
|
|
174
|
+
datos = {
|
|
175
|
+
campo_url: url_media,
|
|
176
|
+
"caption": componer_caption(post.body, post.hashtags),
|
|
177
|
+
"access_token": token,
|
|
178
|
+
}
|
|
179
|
+
if asset.kind is MediaKind.VIDEO:
|
|
180
|
+
# Sin media_type, la Graph API podría tratar el vídeo como un
|
|
181
|
+
# post de feed clásico en vez de un Reel; se fija de forma
|
|
182
|
+
# explícita para no depender de un valor por defecto que Meta
|
|
183
|
+
# puede cambiar.
|
|
184
|
+
datos["media_type"] = "REELS"
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
creacion = client.post(f"{GRAFO}/{ig_user_id}/media", data=datos)
|
|
188
|
+
except httpx.InvalidURL:
|
|
189
|
+
# ig_user_id viene de accounts.yml: mismo razonamiento que con
|
|
190
|
+
# media_url_base más arriba, pero aquí construye la URL del
|
|
191
|
+
# propio Grafo.
|
|
192
|
+
return self._error(
|
|
193
|
+
f"the configured ig_user_id ({ig_user_id!r}) produces an invalid URL"
|
|
194
|
+
)
|
|
195
|
+
except httpx.TimeoutException:
|
|
196
|
+
return self._error(
|
|
197
|
+
'timed out creating the Instagram media container'
|
|
198
|
+
)
|
|
199
|
+
except httpx.HTTPError:
|
|
200
|
+
return self._error(
|
|
201
|
+
'could not connect to Instagram to create the media container'
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if creacion.status_code != 200:
|
|
205
|
+
return self._error(mensaje_de_error(creacion, token))
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
contenedor_id = creacion.json()["id"]
|
|
209
|
+
except json.JSONDecodeError:
|
|
210
|
+
return self._error(
|
|
211
|
+
'Instagram returned an invalid JSON response while '
|
|
212
|
+
'creating the media container'
|
|
213
|
+
)
|
|
214
|
+
except KeyError:
|
|
215
|
+
return self._error('Instagram returned no media container ID')
|
|
216
|
+
|
|
217
|
+
# A diferencia del permalink (cosmético: un fallo ahí degrada a
|
|
218
|
+
# `url=None` sin abortar), un `id` de contenedor que no sea una
|
|
219
|
+
# cadena de texto SÍ es un fallo real: sin un id de contenedor
|
|
220
|
+
# válido no hay forma de sondear su estado ni de publicarlo, así
|
|
221
|
+
# que se aborta aquí con un mensaje accionable en vez de dejar que
|
|
222
|
+
# ese valor siga circulando y explote más adelante -por ejemplo, al
|
|
223
|
+
# construir el `PostResult` final con un `platform_id` de tipo
|
|
224
|
+
# equivocado, que pydantic rechazaría con un `ValidationError` opaco
|
|
225
|
+
# que solo atraparía el resguardo genérico de `publish()`-.
|
|
226
|
+
if not isinstance(contenedor_id, str):
|
|
227
|
+
return self._error(
|
|
228
|
+
'Instagram returned an unexpected media container '
|
|
229
|
+
'ID (expected a string, received '
|
|
230
|
+
f"{type(contenedor_id).__name__}); cannot continue without "
|
|
231
|
+
'a valid container ID'
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
error_de_sondeo = self._esperar_procesado(contenedor_id, token, client)
|
|
235
|
+
if error_de_sondeo is not None:
|
|
236
|
+
return error_de_sondeo
|
|
237
|
+
|
|
238
|
+
# Hallazgo de revisión (I1): a partir de aquí, `media_publish` es el
|
|
239
|
+
# punto sin retorno -exactamente como el último chunk en TikTok (ver
|
|
240
|
+
# `socialctl/adapters/tiktok.py`)-. Un timeout o un fallo de conexión
|
|
241
|
+
# AQUÍ no permiten saber si Instagram llegó a aceptar la petición
|
|
242
|
+
# antes de que la respuesta se perdiera: el envío pudo completarse en
|
|
243
|
+
# el servidor aunque el cliente nunca viera la confirmación. Antes,
|
|
244
|
+
# solo el quinto camino posible (un `id` de publicación no textual,
|
|
245
|
+
# más abajo) marcaba `riesgo_duplicado=True`; los otros cuatro -este
|
|
246
|
+
# timeout, este fallo de conexión, y los dos de una respuesta HTTP
|
|
247
|
+
# 200 ilegible que siguen debajo- son la MISMA situación (Instagram
|
|
248
|
+
# ya aceptó el contenido) y se dejaban en `False`, así que `retry`
|
|
249
|
+
# podía volver a llamar a `media_publish` sobre contenido ya
|
|
250
|
+
# publicado y duplicarlo -el timeout, además, es el fallo más
|
|
251
|
+
# probable de los cinco-.
|
|
252
|
+
try:
|
|
253
|
+
publicacion = client.post(
|
|
254
|
+
f"{GRAFO}/{ig_user_id}/media_publish",
|
|
255
|
+
data={"creation_id": contenedor_id, "access_token": token},
|
|
256
|
+
)
|
|
257
|
+
except httpx.TimeoutException:
|
|
258
|
+
return self._error(
|
|
259
|
+
'timed out publishing to Instagram: cannot '
|
|
260
|
+
'confirm whether Instagram accepted the post '
|
|
261
|
+
'before the response was lost. Do NOT retry without '
|
|
262
|
+
'checking the account first, to avoid duplicating the '
|
|
263
|
+
'post.',
|
|
264
|
+
riesgo_duplicado=True,
|
|
265
|
+
)
|
|
266
|
+
except httpx.HTTPError:
|
|
267
|
+
return self._error(
|
|
268
|
+
'could not connect to Instagram to publish: cannot '
|
|
269
|
+
'confirm whether Instagram accepted the post before '
|
|
270
|
+
'the connection was lost. Do NOT retry without checking '
|
|
271
|
+
'the account first, to avoid duplicating the '
|
|
272
|
+
'post.',
|
|
273
|
+
riesgo_duplicado=True,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if publicacion.status_code != 200:
|
|
277
|
+
return self._error(mensaje_de_error(publicacion, token))
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
post_id = publicacion.json()["id"]
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
return self._error(
|
|
283
|
+
'Instagram returned an invalid JSON response after '
|
|
284
|
+
'publishing: the post was accepted (HTTP 200), but its ID '
|
|
285
|
+
'cannot be confirmed or reported. Do NOT retry without '
|
|
286
|
+
'checking the account first, to avoid duplicating the '
|
|
287
|
+
'post.',
|
|
288
|
+
riesgo_duplicado=True,
|
|
289
|
+
)
|
|
290
|
+
except KeyError:
|
|
291
|
+
return self._error(
|
|
292
|
+
'Instagram returned no post ID: the '
|
|
293
|
+
'post was accepted (HTTP 200), but its ID cannot be '
|
|
294
|
+
'confirmed or reported. Do NOT retry without checking '
|
|
295
|
+
'the account first, to avoid duplicating the '
|
|
296
|
+
'post.',
|
|
297
|
+
riesgo_duplicado=True,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Igual que con `contenedor_id` más arriba: un `id` de publicación
|
|
301
|
+
# que no sea una cadena de texto es un fallo real, no cosmético
|
|
302
|
+
# como el permalink. En este punto Instagram YA aceptó la
|
|
303
|
+
# publicación (media_publish devolvió 200), así que no hay nada que
|
|
304
|
+
# reintentar; pero sin un id de tipo correcto no se puede confirmar
|
|
305
|
+
# ni reportar esa publicación, así que se aborta con un mensaje
|
|
306
|
+
# accionable en vez de dejar que el valor llegue a `PostResult(
|
|
307
|
+
# platform_id=post_id)` y reviente ahí con un `ValidationError` de
|
|
308
|
+
# pydantic que el resguardo genérico de `publish()` convertiría en
|
|
309
|
+
# el mensaje opaco "ha ocurrido un error inesperado en Instagram" -
|
|
310
|
+
# perdiendo, además, el propio id que sí llegó a confirmarse.
|
|
311
|
+
#
|
|
312
|
+
# A diferencia de cualquier otro `ERROR` de este módulo, este caso
|
|
313
|
+
# concreto NO es seguro de reintentar: la publicación ya existe en
|
|
314
|
+
# Instagram (media_publish ya devolvió 200), el id solo hacía falta
|
|
315
|
+
# para confirmarla y reportarla. `retry` (en `socialctl/cli.py`)
|
|
316
|
+
# vuelve a publicar automáticamente todo lo que haya quedado en
|
|
317
|
+
# `ERROR`; si llamara de nuevo a `media_publish` sobre contenido ya
|
|
318
|
+
# publicado, crearía una segunda publicación duplicada y visible en
|
|
319
|
+
# la cuenta. Por eso, además de marcar `riesgo_duplicado=True` -la
|
|
320
|
+
# señal que `retry` de verdad consulta para negarse a reintentar
|
|
321
|
+
# esta red en automático-, el mensaje no se limita a describir el
|
|
322
|
+
# tipo inesperado: advierte de forma explícita de que la
|
|
323
|
+
# publicación probablemente ya está hecha y de que reintentar puede
|
|
324
|
+
# duplicarla, con una acción concreta (comprobar la cuenta antes de
|
|
325
|
+
# volver a publicar) para quien lo lea.
|
|
326
|
+
if not isinstance(post_id, str):
|
|
327
|
+
return self._error(
|
|
328
|
+
'Instagram returned an unexpected post '
|
|
329
|
+
'ID (expected a string, received '
|
|
330
|
+
f"{type(post_id).__name__}); the post was probably "
|
|
331
|
+
'published on Instagram: media_publish accepted it and '
|
|
332
|
+
'only its returned ID failed validation. Therefore '
|
|
333
|
+
'do NOT retry: another publication could duplicate it. '
|
|
334
|
+
'Check the account before publishing again.',
|
|
335
|
+
riesgo_duplicado=True,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# La publicación ya ha tenido éxito en este punto (media_publish
|
|
339
|
+
# devolvió 200 con un id): un fallo al pedir el permalink jamás
|
|
340
|
+
# puede convertir este resultado en ERROR, así que `_obtener_permalink`
|
|
341
|
+
# nunca lanza y, ante cualquier problema, devuelve `None` en vez de
|
|
342
|
+
# propagar o de inventar una URL a mano.
|
|
343
|
+
return PostResult(
|
|
344
|
+
platform=self.platform,
|
|
345
|
+
status=PostStatus.PUBLICADO,
|
|
346
|
+
platform_id=post_id,
|
|
347
|
+
url=self._obtener_permalink(post_id, token, client),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _obtener_permalink(
|
|
351
|
+
self, media_id: str, token: str, client: httpx.Client
|
|
352
|
+
) -> str | None:
|
|
353
|
+
'Read the public permalink for published media, falling back to the ID URL.'
|
|
354
|
+
try:
|
|
355
|
+
respuesta = client.get(
|
|
356
|
+
f"{GRAFO}/{media_id}",
|
|
357
|
+
params={"fields": "permalink", "access_token": token},
|
|
358
|
+
)
|
|
359
|
+
if respuesta.status_code != 200:
|
|
360
|
+
return None
|
|
361
|
+
permalink = respuesta.json()["permalink"]
|
|
362
|
+
return permalink if isinstance(permalink, str) else None
|
|
363
|
+
except Exception:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
def _esperar_procesado(
|
|
367
|
+
self, contenedor_id: str, token: str, client: httpx.Client
|
|
368
|
+
) -> PostResult | None:
|
|
369
|
+
'Poll the media container until ready, failed, expired or timed out.\n\nFINISHED permits publication. ERROR and EXPIRED terminate with actionable\nerrors. Unknown or in-progress statuses keep polling up to the configured limit.\nNetwork and malformed-response failures return error text without throwing.'
|
|
370
|
+
for intento in range(self.intentos):
|
|
371
|
+
try:
|
|
372
|
+
estado = client.get(
|
|
373
|
+
f"{GRAFO}/{contenedor_id}",
|
|
374
|
+
params={"fields": "status_code", "access_token": token},
|
|
375
|
+
)
|
|
376
|
+
except httpx.TimeoutException:
|
|
377
|
+
return self._error(
|
|
378
|
+
'timed out checking the status of the '
|
|
379
|
+
'Instagram media container'
|
|
380
|
+
)
|
|
381
|
+
except httpx.HTTPError:
|
|
382
|
+
return self._error(
|
|
383
|
+
'could not connect to Instagram to check the '
|
|
384
|
+
'media container status'
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if estado.status_code != 200:
|
|
388
|
+
return self._error(mensaje_de_error(estado, token))
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
codigo = estado.json()["status_code"]
|
|
392
|
+
except json.JSONDecodeError:
|
|
393
|
+
return self._error(
|
|
394
|
+
'Instagram returned an invalid JSON response '
|
|
395
|
+
'when checking the media container'
|
|
396
|
+
)
|
|
397
|
+
except KeyError:
|
|
398
|
+
return self._error(
|
|
399
|
+
'Instagram returned no status_code when checking the '
|
|
400
|
+
'media container'
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if codigo == "FINISHED":
|
|
404
|
+
return None
|
|
405
|
+
if codigo == "ERROR":
|
|
406
|
+
return self._error(
|
|
407
|
+
'Instagram could not process the media (the '
|
|
408
|
+
'container is in ERROR status)'
|
|
409
|
+
)
|
|
410
|
+
if codigo == "EXPIRED":
|
|
411
|
+
# Terminal, igual que ERROR (ver docstring de este método):
|
|
412
|
+
# el contenedor caduca a las 24h sin publicar y nunca va a
|
|
413
|
+
# llegar a FINISHED, así que seguir sondeando solo agotaría
|
|
414
|
+
# self.intentos sin motivo antes de fallar con un mensaje
|
|
415
|
+
# genérico de timeout que ocultaría la causa real.
|
|
416
|
+
return self._error(
|
|
417
|
+
'the Instagram media container expired '
|
|
418
|
+
'(status_code=EXPIRED) before publication: it was not '
|
|
419
|
+
'published within 24 hours; create a new container '
|
|
420
|
+
'to try again'
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# No dormir tras el último intento agotado: no serviría de nada
|
|
424
|
+
# y solo ralentizaría cada publicación que acaba fallando.
|
|
425
|
+
if intento < self.intentos - 1:
|
|
426
|
+
time.sleep(self.espera_s)
|
|
427
|
+
|
|
428
|
+
return self._error(
|
|
429
|
+
f"the Instagram container did not finish processing in time "
|
|
430
|
+
f"after {self.intentos} attempts"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _error(self, mensaje: str, *, riesgo_duplicado: bool = False) -> PostResult:
|
|
434
|
+
return PostResult(
|
|
435
|
+
platform=self.platform,
|
|
436
|
+
status=PostStatus.ERROR,
|
|
437
|
+
error=mensaje,
|
|
438
|
+
riesgo_duplicado=riesgo_duplicado,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
ADAPTADORES[Platform.INSTAGRAM] = InstagramAdapter
|