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,535 @@
|
|
|
1
|
+
"""TikTok Content Posting API v2 creator-reviewed inbox upload.
|
|
2
|
+
|
|
3
|
+
Fresh Direct Post is disabled and excluded regardless of legacy audit settings.
|
|
4
|
+
Retained direct-mode helpers do not enable that route. Inbox success remains
|
|
5
|
+
pending creator confirmation. Upload response loss retains duplicate risk and
|
|
6
|
+
requires remote verification before retrying; credentials stay out of diagnostics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from socialctl.adapters.base import ADAPTADORES, Adapter
|
|
18
|
+
from socialctl.adapters.errores import mensaje_de_error
|
|
19
|
+
from socialctl.auth import obtener_token
|
|
20
|
+
from socialctl.brands import Brand
|
|
21
|
+
from socialctl.formatter import PLATFORM_SPECS, componer_caption
|
|
22
|
+
from socialctl.models import (
|
|
23
|
+
MediaAsset,
|
|
24
|
+
Platform,
|
|
25
|
+
PlatformPost,
|
|
26
|
+
PostResult,
|
|
27
|
+
PostStatus,
|
|
28
|
+
ValidationError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
API = "https://open.tiktokapis.com/v2/post/publish"
|
|
32
|
+
|
|
33
|
+
# --- Reglas de chunking del Media Transfer Guide de TikTok: protocolo de
|
|
34
|
+
# subida, no límite de contenido (ver docstring del módulo).
|
|
35
|
+
CHUNK_MIN_BYTES = 5 * 1024 * 1024
|
|
36
|
+
CHUNK_MAX_BYTES = 64 * 1024 * 1024
|
|
37
|
+
CHUNK_MAX_FINAL_BYTES = 128 * 1024 * 1024
|
|
38
|
+
TOTAL_CHUNKS_MAXIMO = 1000
|
|
39
|
+
|
|
40
|
+
# El nivel de privacidad que este proyecto espera al publicar en modo
|
|
41
|
+
# direct: contenido público. Ver docstring de
|
|
42
|
+
# TikTokAdapter._resolver_privacidad_y_duracion sobre por qué es un valor
|
|
43
|
+
# único hoy (no configurable por marca) y qué pasa si la cuenta no lo
|
|
44
|
+
# admite.
|
|
45
|
+
_PRIVACY_LEVEL_DESEADO = "PUBLIC_TO_EVERYONE"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_VALOR_AUDITADA_VERDADERO = "true"
|
|
49
|
+
_VALOR_AUDITADA_FALSO = "false"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _interpretar_auditada(valor: object) -> tuple[bool, bool]:
|
|
53
|
+
'Accept only bool True or the canonical text true as evidence of configured audit.\n\nFalse, None, empty text and canonical false are unaudited. Unknown values such\nas numeric 1 or noncanonical strings return None so callers can fail closed.\nThe configuration value never establishes actual provider approval by itself.'
|
|
54
|
+
if valor is True:
|
|
55
|
+
return True, True
|
|
56
|
+
if valor is False or valor is None:
|
|
57
|
+
return False, True
|
|
58
|
+
if isinstance(valor, str):
|
|
59
|
+
normalizado = valor.strip().lower()
|
|
60
|
+
if normalizado == _VALOR_AUDITADA_VERDADERO:
|
|
61
|
+
return True, True
|
|
62
|
+
if normalizado == _VALOR_AUDITADA_FALSO:
|
|
63
|
+
return False, True
|
|
64
|
+
return False, False
|
|
65
|
+
# Cualquier otro tipo (int, float, list, dict...) -incluidos 1 y 0- no
|
|
66
|
+
# es una forma reconocida.
|
|
67
|
+
return False, False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuditoriaRequerida(Exception):
|
|
71
|
+
'Direct mode requires an approved TikTok application audit.'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ChunkingImposible(Exception):
|
|
75
|
+
'The video exceeds the 1000-chunk maximum supported by TikTok uploads.'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _calcular_chunking(tamano_bytes: int) -> tuple[int, int]:
|
|
79
|
+
'Return chunk_size and total_chunk_count for a file of tamano_bytes.\n\nUse one chunk for up to 64 MB; use sequential 64 MB chunks for larger files.\nEnforce TikTok minimum, maximum final-chunk size and 1000-chunk limit. The return\nvalue describes transfer protocol only, not content validation.'
|
|
80
|
+
if tamano_bytes <= CHUNK_MAX_BYTES:
|
|
81
|
+
# Cubre tanto "menos de 5 MB" como "entre 5 y 64 MB": en ambos
|
|
82
|
+
# casos un único chunk que mida el fichero entero ya respeta las
|
|
83
|
+
# reglas (es, como mucho, de 64 MB, y como pieza única no está
|
|
84
|
+
# sujeto al mínimo de 5 MB que sí aplica a un chunk regular).
|
|
85
|
+
return tamano_bytes, 1
|
|
86
|
+
|
|
87
|
+
# Ceiling division entera (sin pasar por floats, para no arriesgar
|
|
88
|
+
# ninguna imprecisión de coma flotante con ficheros de decenas de GB).
|
|
89
|
+
total_chunk_count = -(-tamano_bytes // CHUNK_MAX_BYTES)
|
|
90
|
+
|
|
91
|
+
if total_chunk_count > TOTAL_CHUNKS_MAXIMO:
|
|
92
|
+
raise ChunkingImposible(
|
|
93
|
+
f"the video is {tamano_bytes / 1024**3:.2f} GB and exceeds the "
|
|
94
|
+
f"maximum of {TOTAL_CHUNKS_MAXIMO} chunks supported by "
|
|
95
|
+
f"TikTok uploads (would require {total_chunk_count}); cannot upload "
|
|
96
|
+
'using this mechanism.'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
chunk_size = -(-tamano_bytes // total_chunk_count)
|
|
100
|
+
|
|
101
|
+
return chunk_size, total_chunk_count
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _ErrorDeSubida(NamedTuple):
|
|
105
|
+
'A file or chunk upload failed, with actionable text and duplicate-risk state.'
|
|
106
|
+
|
|
107
|
+
mensaje: str
|
|
108
|
+
riesgo_duplicado: bool = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _subir_chunks(
|
|
112
|
+
client: httpx.Client,
|
|
113
|
+
upload_url: str,
|
|
114
|
+
path: Path,
|
|
115
|
+
tamano_bytes: int,
|
|
116
|
+
chunk_size: int,
|
|
117
|
+
total_chunk_count: int,
|
|
118
|
+
) -> _ErrorDeSubida | None:
|
|
119
|
+
'Stream original file bytes to upload_url in sequential chunks.\n\nDetect changes in file size, I/O failures, invalid URLs and provider rejection.\nLost responses to the final chunk carry duplicate risk: TikTok may have received\nthe complete file. Return _ErrorDeSubida without exposing credentials.'
|
|
120
|
+
try:
|
|
121
|
+
with path.open("rb") as fichero:
|
|
122
|
+
inicio = 0
|
|
123
|
+
for numero in range(total_chunk_count):
|
|
124
|
+
es_ultimo = numero == total_chunk_count - 1
|
|
125
|
+
fin = (tamano_bytes - 1) if es_ultimo else (inicio + chunk_size - 1)
|
|
126
|
+
longitud = fin - inicio + 1
|
|
127
|
+
|
|
128
|
+
fichero.seek(inicio)
|
|
129
|
+
trozo = fichero.read(longitud)
|
|
130
|
+
if len(trozo) != longitud:
|
|
131
|
+
return _ErrorDeSubida(
|
|
132
|
+
'the video file changed size during the upload '
|
|
133
|
+
f"(chunk {numero + 1}/{total_chunk_count} expected "
|
|
134
|
+
f"{longitud} bytes, but only read {len(trozo)})"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
respuesta = client.put(
|
|
139
|
+
upload_url,
|
|
140
|
+
content=trozo,
|
|
141
|
+
headers={
|
|
142
|
+
"Content-Type": "video/mp4",
|
|
143
|
+
"Content-Length": str(longitud),
|
|
144
|
+
"Content-Range": f"bytes {inicio}-{fin}/{tamano_bytes}",
|
|
145
|
+
},
|
|
146
|
+
timeout=None,
|
|
147
|
+
)
|
|
148
|
+
except httpx.InvalidURL:
|
|
149
|
+
# upload_url la devuelve TikTok en la respuesta de init:
|
|
150
|
+
# si viniera mal formada, httpx la rechaza antes de
|
|
151
|
+
# tocar la red. No hereda de httpx.HTTPError.
|
|
152
|
+
return _ErrorDeSubida('TikTok returned an invalid upload URL')
|
|
153
|
+
except httpx.TimeoutException:
|
|
154
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese
|
|
155
|
+
# except para no quedar inalcanzable.
|
|
156
|
+
if es_ultimo:
|
|
157
|
+
return _ErrorDeSubida(
|
|
158
|
+
'timed out uploading the final '
|
|
159
|
+
f"chunk ({numero + 1}/{total_chunk_count}) to "
|
|
160
|
+
'TikTok: cannot confirm whether TikTok '
|
|
161
|
+
'received it and started publishing the video. Do NOT '
|
|
162
|
+
'retry the upload without checking the '
|
|
163
|
+
'account first, to avoid duplicating the '
|
|
164
|
+
'post.',
|
|
165
|
+
riesgo_duplicado=True,
|
|
166
|
+
)
|
|
167
|
+
return _ErrorDeSubida(
|
|
168
|
+
'timed out uploading chunk '
|
|
169
|
+
f"{numero + 1}/{total_chunk_count} to TikTok"
|
|
170
|
+
)
|
|
171
|
+
except httpx.HTTPError:
|
|
172
|
+
if es_ultimo:
|
|
173
|
+
return _ErrorDeSubida(
|
|
174
|
+
'could not connect to TikTok when uploading the '
|
|
175
|
+
f"final chunk ({numero + 1}/{total_chunk_count}): "
|
|
176
|
+
'cannot confirm whether TikTok '
|
|
177
|
+
'received it and started publishing the video. Do NOT '
|
|
178
|
+
'retry the upload without checking the '
|
|
179
|
+
'account first, to avoid duplicating the '
|
|
180
|
+
'post.',
|
|
181
|
+
riesgo_duplicado=True,
|
|
182
|
+
)
|
|
183
|
+
return _ErrorDeSubida(
|
|
184
|
+
'could not connect to TikTok to upload chunk '
|
|
185
|
+
f"{numero + 1}/{total_chunk_count}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if respuesta.status_code not in (200, 201, 206):
|
|
189
|
+
return _ErrorDeSubida(
|
|
190
|
+
f"TikTok rejected chunk {numero + 1}/{total_chunk_count} "
|
|
191
|
+
f"of the upload (HTTP {respuesta.status_code})"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
inicio = fin + 1
|
|
195
|
+
except FileNotFoundError:
|
|
196
|
+
return _ErrorDeSubida(f"the video file no longer exists on disk: {path}")
|
|
197
|
+
except IsADirectoryError:
|
|
198
|
+
return _ErrorDeSubida(
|
|
199
|
+
f"the video path points to a directory, not a file: {path}"
|
|
200
|
+
)
|
|
201
|
+
except PermissionError:
|
|
202
|
+
return _ErrorDeSubida(f"the video file is not readable: {path}")
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
# Cualquier otro fallo de E/S al leer el fichero (disco dañado,
|
|
205
|
+
# demasiados descriptores abiertos, etc.).
|
|
206
|
+
return _ErrorDeSubida(f"could not read the video file from disk ({path}): {exc}")
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _consultar_creator_info(
|
|
212
|
+
client: httpx.Client, token: str
|
|
213
|
+
) -> tuple[dict | None, str | None]:
|
|
214
|
+
'Query /v2/post/publish/creator_info/query/ with the authenticated token.\n\nReturn creator data or a diagnostic error; this helper performs no publication.\nCallers validate privacy_level_options and max_video_post_duration_sec.'
|
|
215
|
+
try:
|
|
216
|
+
respuesta = client.post(
|
|
217
|
+
f"{API}/creator_info/query/",
|
|
218
|
+
headers={
|
|
219
|
+
"Authorization": f"Bearer {token}",
|
|
220
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
except httpx.TimeoutException:
|
|
224
|
+
return None, 'timed out querying creator_info on TikTok'
|
|
225
|
+
except httpx.HTTPError:
|
|
226
|
+
return None, 'could not connect to TikTok to query creator_info'
|
|
227
|
+
|
|
228
|
+
if respuesta.status_code != 200:
|
|
229
|
+
return None, mensaje_de_error(respuesta, token)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
cuerpo = respuesta.json()
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
return None, 'TikTok returned an invalid JSON response'
|
|
235
|
+
|
|
236
|
+
datos = cuerpo.get("data") if isinstance(cuerpo, dict) else None
|
|
237
|
+
if not isinstance(datos, dict):
|
|
238
|
+
return None, "TikTok returned no creator_info data (missing 'data' field)"
|
|
239
|
+
|
|
240
|
+
return datos, None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TikTokAdapter(Adapter):
|
|
244
|
+
'Upload creator-reviewed TikTok inbox drafts; fresh Direct Post is excluded.'
|
|
245
|
+
|
|
246
|
+
platform = Platform.TIKTOK
|
|
247
|
+
|
|
248
|
+
def validate(self, post: PlatformPost, brand: Brand) -> list[ValidationError]:
|
|
249
|
+
'Validate TikTok account, upload mode and stable scope without network access.'
|
|
250
|
+
errores = super().validate(post, brand)
|
|
251
|
+
cuenta = brand.cuentas.get("tiktok") or {}
|
|
252
|
+
open_id = cuenta.get("open_id")
|
|
253
|
+
modo = cuenta.get("mode", "inbox")
|
|
254
|
+
# No se convierte con bool(): ver docstring de _interpretar_auditada.
|
|
255
|
+
valor_auditada = cuenta.get("auditada")
|
|
256
|
+
|
|
257
|
+
if not open_id:
|
|
258
|
+
errores.append(
|
|
259
|
+
ValidationError(
|
|
260
|
+
platform=self.platform,
|
|
261
|
+
campo="cuenta",
|
|
262
|
+
motivo=f"missing open_id in {brand.raiz / 'accounts.yml'}",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if modo not in ("inbox", "direct"):
|
|
267
|
+
errores.append(
|
|
268
|
+
ValidationError(
|
|
269
|
+
platform=self.platform,
|
|
270
|
+
campo="cuenta",
|
|
271
|
+
motivo=(
|
|
272
|
+
f"unknown TikTok mode: '{modo}' in "
|
|
273
|
+
f"{brand.raiz / 'accounts.yml'}. Valid values: "
|
|
274
|
+
"inbox, direct"
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
self._comprobar_auditoria(brand, modo, valor_auditada)
|
|
281
|
+
except AuditoriaRequerida as exc:
|
|
282
|
+
errores.append(
|
|
283
|
+
ValidationError(
|
|
284
|
+
platform=self.platform, campo="auditoria", motivo=str(exc)
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return errores
|
|
289
|
+
|
|
290
|
+
def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
291
|
+
'Publish a validated post; convert every failure to an error PostResult.'
|
|
292
|
+
spec = PLATFORM_SPECS[self.platform]
|
|
293
|
+
if len(post.media) > spec.max_media:
|
|
294
|
+
return self._error(
|
|
295
|
+
f"this endpoint supports at most {spec.max_media} file; "
|
|
296
|
+
'it does not publish carousels'
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
return self._publicar(post, brand, client)
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
return self._error(
|
|
303
|
+
f"an unexpected TikTok error occurred ({type(exc).__name__})"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
307
|
+
'Execute publication with explicit handling of expected failures.'
|
|
308
|
+
cuenta = brand.cuentas.get("tiktok") or {}
|
|
309
|
+
open_id = cuenta.get("open_id")
|
|
310
|
+
modo = cuenta.get("mode", "inbox")
|
|
311
|
+
# No se convierte con bool(): ver docstring de _interpretar_auditada.
|
|
312
|
+
valor_auditada = cuenta.get("auditada")
|
|
313
|
+
|
|
314
|
+
# La Content Posting API v2 no exige open_id en el cuerpo de
|
|
315
|
+
# init/subida (el access_token ya identifica a la cuenta), pero su
|
|
316
|
+
# ausencia en accounts.yml es señal de una cuenta a medio
|
|
317
|
+
# configurar -igual que page_id en facebook.py o ig_user_id en
|
|
318
|
+
# instagram.py-, así que se valida aquí aunque no viaje en ninguna
|
|
319
|
+
# petición de este módulo.
|
|
320
|
+
if not open_id:
|
|
321
|
+
return self._error(f"missing open_id in {brand.raiz / 'accounts.yml'}")
|
|
322
|
+
|
|
323
|
+
if modo not in ("inbox", "direct"):
|
|
324
|
+
return self._error(
|
|
325
|
+
f"unknown TikTok mode: '{modo}' in {brand.raiz / 'accounts.yml'}. "
|
|
326
|
+
'Valid values: inbox, direct'
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
self._comprobar_auditoria(brand, modo, valor_auditada)
|
|
331
|
+
except AuditoriaRequerida as exc:
|
|
332
|
+
return self._error(str(exc))
|
|
333
|
+
|
|
334
|
+
if not post.media:
|
|
335
|
+
return self._error(
|
|
336
|
+
'no video to publish: the post has no media attached'
|
|
337
|
+
)
|
|
338
|
+
asset = post.media[0]
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
token = obtener_token(brand, self.platform, client)
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
return self._error(str(exc))
|
|
344
|
+
|
|
345
|
+
# En modo direct, privacy_level debe coincidir con una opción que
|
|
346
|
+
# ESTA cuenta admita de verdad (ver _resolver_privacidad_y_duracion).
|
|
347
|
+
# La llamada queda DESPUÉS de la salvaguarda de auditoría (ya
|
|
348
|
+
# aplicada más arriba) y ANTES de video/init/: si la cuenta no
|
|
349
|
+
# admite el nivel que este proyecto espera, se aborta aquí, sin
|
|
350
|
+
# haber gastado ninguna llamada de subida.
|
|
351
|
+
privacy_level = None
|
|
352
|
+
if modo == "direct":
|
|
353
|
+
privacy_level, error_creador = self._resolver_privacidad_y_duracion(
|
|
354
|
+
token, asset, client
|
|
355
|
+
)
|
|
356
|
+
if error_creador is not None:
|
|
357
|
+
return error_creador
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
tamano_bytes = asset.path.stat().st_size
|
|
361
|
+
except FileNotFoundError:
|
|
362
|
+
return self._error(f"the video file no longer exists on disk: {asset.path}")
|
|
363
|
+
except OSError as exc:
|
|
364
|
+
return self._error(
|
|
365
|
+
f"could not read the video file from disk ({asset.path}): {exc}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
chunk_size, total_chunk_count = _calcular_chunking(tamano_bytes)
|
|
370
|
+
except ChunkingImposible as exc:
|
|
371
|
+
return self._error(str(exc))
|
|
372
|
+
|
|
373
|
+
titulo = componer_caption(post.body, post.hashtags)
|
|
374
|
+
|
|
375
|
+
cuerpo: dict = {
|
|
376
|
+
"source_info": {
|
|
377
|
+
"source": "FILE_UPLOAD",
|
|
378
|
+
"video_size": tamano_bytes,
|
|
379
|
+
"chunk_size": chunk_size,
|
|
380
|
+
"total_chunk_count": total_chunk_count,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if modo == "direct":
|
|
384
|
+
endpoint = f"{API}/video/init/"
|
|
385
|
+
cuerpo["post_info"] = {
|
|
386
|
+
"title": titulo,
|
|
387
|
+
# Confirmado más arriba contra creator_info/query/: no un
|
|
388
|
+
# valor fijo sin consultar (ver _resolver_privacidad_y_duracion).
|
|
389
|
+
"privacy_level": privacy_level,
|
|
390
|
+
}
|
|
391
|
+
else:
|
|
392
|
+
endpoint = f"{API}/inbox/video/init/"
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
inicio = client.post(
|
|
396
|
+
endpoint,
|
|
397
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
398
|
+
json=cuerpo,
|
|
399
|
+
)
|
|
400
|
+
except httpx.TimeoutException:
|
|
401
|
+
return self._error(
|
|
402
|
+
'timed out contacting TikTok to '
|
|
403
|
+
'initialize the upload'
|
|
404
|
+
)
|
|
405
|
+
except httpx.HTTPError:
|
|
406
|
+
return self._error('could not connect to TikTok to initialize the upload')
|
|
407
|
+
|
|
408
|
+
if inicio.status_code != 200:
|
|
409
|
+
return self._error(mensaje_de_error(inicio, token))
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
cuerpo_respuesta = inicio.json()
|
|
413
|
+
except json.JSONDecodeError:
|
|
414
|
+
return self._error(
|
|
415
|
+
'TikTok returned an invalid JSON response when attempting to '
|
|
416
|
+
'initialize the upload'
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
datos = cuerpo_respuesta.get("data") if isinstance(cuerpo_respuesta, dict) else None
|
|
420
|
+
if not isinstance(datos, dict):
|
|
421
|
+
return self._error(
|
|
422
|
+
"TikTok returned no upload data (missing 'data' field)"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
upload_url = datos.get("upload_url")
|
|
426
|
+
if not isinstance(upload_url, str) or not upload_url:
|
|
427
|
+
return self._error('TikTok did not return a valid upload URL')
|
|
428
|
+
|
|
429
|
+
publish_id = datos.get("publish_id")
|
|
430
|
+
# Se valida el tipo AQUÍ, antes de subir ningún byte: en este punto
|
|
431
|
+
# TikTok solo ha reservado un id de seguimiento, no ha recibido
|
|
432
|
+
# contenido, así que fallar ahora (en vez de después de subir) es
|
|
433
|
+
# siempre seguro de reintentar -ver el aviso de duplicados en el
|
|
434
|
+
# docstring del módulo-.
|
|
435
|
+
if publish_id is not None and not isinstance(publish_id, str):
|
|
436
|
+
return self._error(
|
|
437
|
+
'TikTok returned an unexpected publish_id (expected '
|
|
438
|
+
f"a string, received {type(publish_id).__name__})"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
error_subida = _subir_chunks(
|
|
442
|
+
client, upload_url, asset.path, tamano_bytes, chunk_size, total_chunk_count
|
|
443
|
+
)
|
|
444
|
+
if error_subida is not None:
|
|
445
|
+
return self._error(
|
|
446
|
+
error_subida.mensaje, riesgo_duplicado=error_subida.riesgo_duplicado
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if modo == "direct":
|
|
450
|
+
return PostResult(
|
|
451
|
+
platform=self.platform,
|
|
452
|
+
status=PostStatus.PUBLICADO,
|
|
453
|
+
platform_id=publish_id,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return PostResult(
|
|
457
|
+
platform=self.platform,
|
|
458
|
+
status=PostStatus.PENDIENTE_CONFIRMACION,
|
|
459
|
+
platform_id=publish_id,
|
|
460
|
+
error='pending: open TikTok and confirm the publication',
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def _resolver_privacidad_y_duracion(
|
|
464
|
+
self, token: str, asset: MediaAsset, client: httpx.Client
|
|
465
|
+
) -> tuple[str | None, PostResult | None]:
|
|
466
|
+
'In direct mode, query creator info and check privacy and duration.\n\nThe configured PUBLIC_TO_EVERYONE level must appear in privacy_level_options.\nReject unsupported privacy rather than selecting a different level. The video\nmust fit the returned max_video_post_duration_sec. Return either the validated\nprivacy level or an error PostResult, without sending content.'
|
|
467
|
+
datos, error_creador = _consultar_creator_info(client, token)
|
|
468
|
+
if error_creador is not None:
|
|
469
|
+
return None, self._error(
|
|
470
|
+
f"could not query creator_info on TikTok (required "
|
|
471
|
+
f"before publication in mode: direct): {error_creador}"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
opciones = datos.get("privacy_level_options")
|
|
475
|
+
if not isinstance(opciones, list) or not all(
|
|
476
|
+
isinstance(o, str) for o in opciones
|
|
477
|
+
):
|
|
478
|
+
return None, self._error(
|
|
479
|
+
'TikTok returned no valid list of '
|
|
480
|
+
'privacy_level_options in creator_info: cannot '
|
|
481
|
+
'confirm which privacy levels this account supports'
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if _PRIVACY_LEVEL_DESEADO not in opciones:
|
|
485
|
+
return None, self._error(
|
|
486
|
+
'the TikTok account does not support publishing with '
|
|
487
|
+
f"privacy_level={_PRIVACY_LEVEL_DESEADO!r} (the level "
|
|
488
|
+
'this project expects for mode: direct); this account '
|
|
489
|
+
f"supports these options: {opciones!r}. It may "
|
|
490
|
+
'be a private or underage account: review the '
|
|
491
|
+
'account privacy settings in '
|
|
492
|
+
'TikTok, or use mode: inbox to confirm the '
|
|
493
|
+
'publication manually. No publication is made with a '
|
|
494
|
+
'different privacy level from the expected one.'
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
maximo = datos.get("max_video_post_duration_sec")
|
|
498
|
+
duracion = asset.duration_s
|
|
499
|
+
if (
|
|
500
|
+
isinstance(maximo, (int, float))
|
|
501
|
+
and duracion is not None
|
|
502
|
+
and duracion > maximo
|
|
503
|
+
):
|
|
504
|
+
return None, self._error(
|
|
505
|
+
f"the video is {duracion:.0f}s long and exceeds the {maximo:.0f}s "
|
|
506
|
+
'maximum supported by this account for a direct post '
|
|
507
|
+
'(max_video_post_duration_sec from creator_info, a '
|
|
508
|
+
'creator-specific limit that may be lower than the general '
|
|
509
|
+
'TikTok limit); use mode: inbox or provide a shorter video.'
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return _PRIVACY_LEVEL_DESEADO, None
|
|
513
|
+
|
|
514
|
+
def _comprobar_auditoria(self, brand: Brand, modo: str, valor_auditada: object) -> None:
|
|
515
|
+
'Enforce the stable Direct Post exclusion before credentials or provider access.'
|
|
516
|
+
if modo != "direct":
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
raise AuditoriaRequerida(
|
|
520
|
+
"TikTok Direct Post is disabled and excluded from the current offer, "
|
|
521
|
+
"regardless of auditada configuration. Use mode: inbox for supported "
|
|
522
|
+
"creator-reviewed inbox upload; the creator must finish publishing in TikTok. "
|
|
523
|
+
"No upload was attempted."
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def _error(self, mensaje: str, *, riesgo_duplicado: bool = False) -> PostResult:
|
|
527
|
+
return PostResult(
|
|
528
|
+
platform=self.platform,
|
|
529
|
+
status=PostStatus.ERROR,
|
|
530
|
+
error=mensaje,
|
|
531
|
+
riesgo_duplicado=riesgo_duplicado,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
ADAPTADORES[Platform.TIKTOK] = TikTokAdapter
|