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
socialctl/cli.py
ADDED
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
"""The socialcli command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import warnings
|
|
13
|
+
import webbrowser
|
|
14
|
+
from datetime import date, datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import typer
|
|
19
|
+
from socialctl.approval_cli import read_approval
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from socialctl.authflow import (
|
|
23
|
+
AYUDA_META,
|
|
24
|
+
EstadoInvalido,
|
|
25
|
+
canjear_codigo,
|
|
26
|
+
construir_url_autorizacion,
|
|
27
|
+
generar_code_verifier,
|
|
28
|
+
generar_state,
|
|
29
|
+
intercambiar_token_meta,
|
|
30
|
+
obtener_paginas_meta,
|
|
31
|
+
verificar_state,
|
|
32
|
+
)
|
|
33
|
+
from socialctl.brands import (
|
|
34
|
+
AccountsInvalido,
|
|
35
|
+
Brand,
|
|
36
|
+
BrandNoEncontrada,
|
|
37
|
+
BrandYaExiste,
|
|
38
|
+
NombreDeMarcaInvalido,
|
|
39
|
+
cargar_brand,
|
|
40
|
+
crear_brand,
|
|
41
|
+
)
|
|
42
|
+
from socialctl.media import MediaInvalida, MediaNoEncontrada
|
|
43
|
+
from socialctl.media_cli import media_app
|
|
44
|
+
from socialctl.management.cli import changes_app, content_app
|
|
45
|
+
from socialctl.management.comments_cli import comments_app
|
|
46
|
+
from socialctl.management.meta_cli import meta_app
|
|
47
|
+
from socialctl.management.stories_cli import story_app
|
|
48
|
+
from socialctl.management.groups_cli import group_app
|
|
49
|
+
from socialctl.migration_cli import migration_app
|
|
50
|
+
from socialctl.schedule_remote import remote_app, is_remote, REMOTE_NOTICE
|
|
51
|
+
from socialctl.native_schedule.cli import native_app
|
|
52
|
+
from socialctl.metricas import LECTORES, leer_red # noqa: F401 (importarlo registra)
|
|
53
|
+
from socialctl.metricas.almacen import (
|
|
54
|
+
RETENCION_SNAPSHOTS_DIAS,
|
|
55
|
+
cargar_snapshot,
|
|
56
|
+
guardar_snapshot,
|
|
57
|
+
limpiar_snapshots_antiguos,
|
|
58
|
+
snapshot_anterior,
|
|
59
|
+
)
|
|
60
|
+
from socialctl.metricas.modelos import EstadoLectura, Snapshot
|
|
61
|
+
from socialctl.metricas.piezas import PiezasIlegibles, actualizar_piezas
|
|
62
|
+
from socialctl.metricas.resumen import escribir_resumen
|
|
63
|
+
from socialctl.metricas.slugs import asignar_slugs
|
|
64
|
+
from socialctl.models import Platform, Post, PostStatus
|
|
65
|
+
from socialctl.workspace import default_root
|
|
66
|
+
from socialctl.postfile import PostNoEncontrado, cargar_post
|
|
67
|
+
from socialctl.publication_status import describe_result_with_observation
|
|
68
|
+
from socialctl.read_reports import safe_error
|
|
69
|
+
from socialctl.publish_output import PublishOutput
|
|
70
|
+
from socialctl.publisher import (
|
|
71
|
+
PersistenciaError,
|
|
72
|
+
anexar_historial,
|
|
73
|
+
guardar_resultado,
|
|
74
|
+
publicar,
|
|
75
|
+
render_preview,
|
|
76
|
+
validar_todo,
|
|
77
|
+
)
|
|
78
|
+
from socialctl.scheduler import (
|
|
79
|
+
ScheduleEntry,
|
|
80
|
+
ScheduleError,
|
|
81
|
+
ScheduleStore,
|
|
82
|
+
approval_hash,
|
|
83
|
+
approval_review_reason,
|
|
84
|
+
now_utc,
|
|
85
|
+
parse_scheduled_at,
|
|
86
|
+
schedule_digest,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
app = typer.Typer(help="Publish and manage your social content. Setup: brand new NAME, credentials generate-key/configure (headless), account login, then connect PLATFORM. Run agent-guide for the complete workflow.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command('agent-guide')
|
|
93
|
+
def agent_guide(topic: str = typer.Argument('setup', help='setup, publish, schedule, inbox, errors, permissions or all')):
|
|
94
|
+
"""Read a focused agent guide offline, without an account or workspace."""
|
|
95
|
+
from socialctl.agent_guide import guide
|
|
96
|
+
try:
|
|
97
|
+
typer.echo(guide(topic))
|
|
98
|
+
except ValueError as error:
|
|
99
|
+
typer.echo(str(error),err=True)
|
|
100
|
+
raise typer.Exit(2) from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command('report')
|
|
104
|
+
def brand_report(
|
|
105
|
+
brand_nombre: str = typer.Option(..., '--brand'),
|
|
106
|
+
root: Path = typer.Option(default_root(), '--root'),
|
|
107
|
+
days: str = typer.Option('7', '--days', help='Daily comparison window, 1..365.'),
|
|
108
|
+
as_of: str | None = typer.Option(None, '--as-of', help='Last allowed snapshot date, YYYY-MM-DD.'),
|
|
109
|
+
top: str = typer.Option('5', '--top', help='Top observed items per platform, 1..50.'),
|
|
110
|
+
only: str | None = typer.Option(None, '--only'),
|
|
111
|
+
json_output: bool = typer.Option(False, '--json'),
|
|
112
|
+
):
|
|
113
|
+
"""Report growth and top content from saved snapshots; no network or account access."""
|
|
114
|
+
from socialctl.metricas.insights import build_report, render_report
|
|
115
|
+
from socialctl.read_reports import ReadReport, emit_report
|
|
116
|
+
exit_code = None
|
|
117
|
+
try:
|
|
118
|
+
window, limit = int(days), int(top)
|
|
119
|
+
day = date.fromisoformat(as_of) if as_of else date.today()
|
|
120
|
+
platform = Platform(only) if only else None
|
|
121
|
+
if not 1 <= window <= 365 or not 1 <= limit <= 50:
|
|
122
|
+
raise ValueError()
|
|
123
|
+
except ValueError:
|
|
124
|
+
result = ReadReport(status='error', errors=[{'code':'invalid_report_options',
|
|
125
|
+
'action':'correct_input', 'retryable':False,
|
|
126
|
+
'message':'Use days 1..365, top 1..50, a YYYY-MM-DD date and a supported platform.'}])
|
|
127
|
+
exit_code = 2
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
marca = cargar_brand(root, brand_nombre)
|
|
131
|
+
result = build_report(marca, days=window, as_of=day, top=limit, only=platform)
|
|
132
|
+
except (*_EXCEPCIONES_MARCA, OSError, ValueError, OverflowError):
|
|
133
|
+
result = ReadReport(status='error', errors=[{'code':'report_workspace_unavailable',
|
|
134
|
+
'action':'inspect_workspace', 'retryable':False}])
|
|
135
|
+
if json_output:
|
|
136
|
+
code = emit_report(result)
|
|
137
|
+
else:
|
|
138
|
+
typer.echo(render_report(result) if result.data else result.errors[0].get('message', result.errors[0]['code']))
|
|
139
|
+
from socialctl.read_reports import read_exit_code
|
|
140
|
+
code = read_exit_code(result.status)
|
|
141
|
+
raise typer.Exit(exit_code if exit_code is not None else code)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command('delivery-options')
|
|
145
|
+
def show_delivery_options(
|
|
146
|
+
brand_nombre: str = typer.Option(..., '--brand'),
|
|
147
|
+
root: Path = typer.Option(default_root(), '--root'),
|
|
148
|
+
json_output: bool = typer.Option(False, '--json'),
|
|
149
|
+
):
|
|
150
|
+
"""Explain delivery modes and limits; does not authenticate or send media."""
|
|
151
|
+
from socialctl.delivery_options import delivery_options
|
|
152
|
+
result = delivery_options(_cargar_marca(root, brand_nombre))
|
|
153
|
+
if json_output:
|
|
154
|
+
typer.echo(json.dumps(result, indent=2))
|
|
155
|
+
else:
|
|
156
|
+
typer.echo('SocialCli does not store customer media on its SaaS server.')
|
|
157
|
+
for platform, row in result['platforms'].items():
|
|
158
|
+
typer.echo(f"{platform}: {row['selected_delivery']} — {row['instruction']}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _show_version(value: bool):
|
|
162
|
+
if value:
|
|
163
|
+
from importlib.metadata import version
|
|
164
|
+
typer.echo('SocialCli ' + version('socialcli'))
|
|
165
|
+
raise typer.Exit()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.callback()
|
|
169
|
+
def main_options(version: bool = typer.Option(False, '--version', callback=_show_version,
|
|
170
|
+
is_eager=True, help='Show the installed SocialCli version.')):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
from socialctl.connections.credential_cli import credentials_app
|
|
174
|
+
app.add_typer(credentials_app, name="credentials")
|
|
175
|
+
brand_app = typer.Typer(help="Brand management.")
|
|
176
|
+
app.add_typer(brand_app, name="brand")
|
|
177
|
+
app.add_typer(content_app, name="content")
|
|
178
|
+
app.add_typer(changes_app, name="changes")
|
|
179
|
+
app.add_typer(comments_app, name="comments")
|
|
180
|
+
app.add_typer(meta_app, name="meta")
|
|
181
|
+
app.add_typer(story_app, name="story")
|
|
182
|
+
app.add_typer(group_app, name="group")
|
|
183
|
+
app.add_typer(migration_app, name="queue-migration")
|
|
184
|
+
app.add_typer(remote_app, name="schedule-remote")
|
|
185
|
+
app.add_typer(native_app, name="native-schedule")
|
|
186
|
+
app.add_typer(media_app, name="media")
|
|
187
|
+
|
|
188
|
+
from socialctl.inventory_cli import register as register_inventory
|
|
189
|
+
register_inventory(app, content_app)
|
|
190
|
+
|
|
191
|
+
from socialctl.connections.cli import register as register_connections
|
|
192
|
+
register_connections(app)
|
|
193
|
+
from socialctl.connections.account_cli import account_app
|
|
194
|
+
app.add_typer(account_app,name="account")
|
|
195
|
+
from socialctl.management.community_batch_cli import register as register_community_batches
|
|
196
|
+
register_community_batches(changes_app, default_root())
|
|
197
|
+
from socialctl.inbox_cli import register as register_inbox
|
|
198
|
+
register_inbox(app, default_root())
|
|
199
|
+
from socialctl.youtube_data import register as register_youtube_data
|
|
200
|
+
register_youtube_data(app)
|
|
201
|
+
from socialctl.youtube_data_service import register as register_youtube_data_service
|
|
202
|
+
register_youtube_data_service(app)
|
|
203
|
+
|
|
204
|
+
RAIZ_POR_DEFECTO = default_root()
|
|
205
|
+
|
|
206
|
+
# Excepciones que cargar_brand puede lanzar y que el CLI debe convertir en un
|
|
207
|
+
# mensaje claro (nunca en una traza de Python).
|
|
208
|
+
_EXCEPCIONES_MARCA = (BrandNoEncontrada, NombreDeMarcaInvalido, AccountsInvalido)
|
|
209
|
+
|
|
210
|
+
# Excepciones que cargar_post puede lanzar. PostInvalido, SlugInvalido (la de
|
|
211
|
+
# postfile.py) y NombreDeMediaInvalido son subclases de ValueError -ver sus
|
|
212
|
+
# docstrings en socialctl/postfile.py-, así que capturar ValueError basta
|
|
213
|
+
# para las tres sin tener que importarlas una a una.
|
|
214
|
+
_EXCEPCIONES_POST = (PostNoEncontrado, MediaNoEncontrada, MediaInvalida, ValueError)
|
|
215
|
+
|
|
216
|
+
# YouTube y TikTok comparten el mismo puerto/redirect_uri de callback local:
|
|
217
|
+
# cada intento de `auth` levanta y cierra su propio servidor de un solo uso
|
|
218
|
+
# (ver `_esperar_codigo`), así que no hay conflicto entre ellos aunque se
|
|
219
|
+
# ejecuten en momentos distintos.
|
|
220
|
+
PUERTO_CALLBACK = 8723
|
|
221
|
+
REDIRECT_URI_OAUTH = f"http://localhost:{PUERTO_CALLBACK}/callback"
|
|
222
|
+
TIMEOUT_CALLBACK_S = 300.0 # 5 minutos: tiempo razonable para completar el login en el navegador
|
|
223
|
+
|
|
224
|
+
_PAGINA_LISTO = (
|
|
225
|
+
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
226
|
+
"<title>socialcli</title></head><body>"
|
|
227
|
+
"<h1>Done. You can close this tab.</h1>"
|
|
228
|
+
"</body></html>"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Hallazgo de revisión: la página que ve el usuario al volver del navegador
|
|
232
|
+
# decía siempre "Listo" aunque la redirección trajera un `error` (el usuario
|
|
233
|
+
# canceló la autorización o el proveedor la rechazó) o un `state` que no
|
|
234
|
+
# coincide (posible CSRF, o simplemente una pestaña vieja de un intento
|
|
235
|
+
# anterior). Las tres páginas son estáticas y fijas a propósito -nunca
|
|
236
|
+
# interpolan `error`, `error_description` ni `state`- porque ese contenido
|
|
237
|
+
# lo controla el proveedor (o quien sea que golpee el puerto del callback) y
|
|
238
|
+
# podría llevar algo inesperado; `_pagina_para` elige entre las tres sin
|
|
239
|
+
# volcar ningún dato de la petición.
|
|
240
|
+
_PAGINA_ERROR_AUTORIZACION = (
|
|
241
|
+
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
242
|
+
"<title>socialcli</title></head><body>"
|
|
243
|
+
"<h1>Authorization did not complete.</h1>"
|
|
244
|
+
"<p>Return to the terminal for details.</p>"
|
|
245
|
+
"</body></html>"
|
|
246
|
+
)
|
|
247
|
+
_PAGINA_ESTADO_INVALIDO = (
|
|
248
|
+
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
249
|
+
"<title>socialcli</title></head><body>"
|
|
250
|
+
"<h1>Something went wrong with this authorization and it was not used.</h1>"
|
|
251
|
+
"<p>Return to the terminal and run the 'auth' command again.</p>"
|
|
252
|
+
"</body></html>"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _pagina_para(recibido: dict[str, str], estado_esperado: str | None) -> str:
|
|
257
|
+
"""Select the static OAuth callback page matching the result."""
|
|
258
|
+
if "error" in recibido:
|
|
259
|
+
return _PAGINA_ERROR_AUTORIZACION
|
|
260
|
+
if estado_esperado is not None and recibido.get("state") != estado_esperado:
|
|
261
|
+
return _PAGINA_ESTADO_INVALIDO
|
|
262
|
+
return _PAGINA_LISTO
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Aviso adicional que se muestra justo antes de publicar en una red cuya
|
|
266
|
+
# publicación implica una espera larga y silenciosa si no se avisa (hallazgo
|
|
267
|
+
# de revisión: el CLI no daba ninguna señal de vida mientras Instagram
|
|
268
|
+
# sondeaba el procesado del vídeo, hasta 5 minutos según la cadencia que
|
|
269
|
+
# recomienda Meta -ver `InstagramAdapter` en `socialctl/adapters/
|
|
270
|
+
# instagram.py`-). Un diccionario y no un `if` disperso porque el mismo
|
|
271
|
+
# patrón podría hacer falta para otra red el día de mañana.
|
|
272
|
+
_AVISO_ESPERA_LARGA = {
|
|
273
|
+
Platform.INSTAGRAM: (
|
|
274
|
+
' (Instagram may take several minutes to verify video processing; allow it time)'
|
|
275
|
+
),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _mostrar_progreso(platform: Platform) -> None:
|
|
280
|
+
"""Report the active platform before publication."""
|
|
281
|
+
typer.echo(f"Publishing on {platform.value}...{_AVISO_ESPERA_LARGA.get(platform, '')}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _esperar_codigo(
|
|
285
|
+
puerto: int,
|
|
286
|
+
estado_esperado: str | None = None,
|
|
287
|
+
timeout_s: float = TIMEOUT_CALLBACK_S,
|
|
288
|
+
) -> dict[str, str]:
|
|
289
|
+
"""Run a single-use localhost callback server and return the authorization code."""
|
|
290
|
+
recibido: dict[str, str] = {}
|
|
291
|
+
|
|
292
|
+
class _Manejador(http.server.BaseHTTPRequestHandler):
|
|
293
|
+
def do_GET(self) -> None: # noqa: N802
|
|
294
|
+
consulta = urllib.parse.urlparse(self.path).query
|
|
295
|
+
recibido.update(dict(urllib.parse.parse_qsl(consulta)))
|
|
296
|
+
self.send_response(200)
|
|
297
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
298
|
+
self.end_headers()
|
|
299
|
+
self.wfile.write(_pagina_para(recibido, estado_esperado).encode("utf-8"))
|
|
300
|
+
|
|
301
|
+
def log_message(self, *args: object) -> None: # silencia el log del servidor
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
servidor = http.server.HTTPServer(("localhost", puerto), _Manejador)
|
|
306
|
+
except OSError as exc:
|
|
307
|
+
raise RuntimeError(
|
|
308
|
+
f'could not open port {puerto} in localhost ({exc}). Is another process using it? Close it and try again.'
|
|
309
|
+
) from None
|
|
310
|
+
|
|
311
|
+
servidor.timeout = timeout_s
|
|
312
|
+
try:
|
|
313
|
+
servidor.handle_request()
|
|
314
|
+
finally:
|
|
315
|
+
servidor.server_close()
|
|
316
|
+
|
|
317
|
+
return recibido
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _fallar(mensaje: str) -> None:
|
|
321
|
+
"""Print an error and exit the command with code 1."""
|
|
322
|
+
typer.echo(mensaje)
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _confirmar(mensaje: str) -> bool:
|
|
327
|
+
"""Request explicit confirmation; empty input or EOF declines. English and legacy Spanish affirmative inputs are accepted."""
|
|
328
|
+
if not sys.stdin.isatty():
|
|
329
|
+
_fallar("Noninteractive approval required: show the complete preview, then use --approval-fingerprint DIGEST or --approval-stdin; --yes remains available for commands that document it. No input was read.")
|
|
330
|
+
typer.echo(f"{mensaje} [y/N]: ", nl=False)
|
|
331
|
+
while True:
|
|
332
|
+
try:
|
|
333
|
+
valor = input().strip().lower()
|
|
334
|
+
except EOFError:
|
|
335
|
+
valor = "n"
|
|
336
|
+
if valor in ("y", "yes", "s", "si", "sí"):
|
|
337
|
+
return True
|
|
338
|
+
if valor in ("n", "no", ""):
|
|
339
|
+
return False
|
|
340
|
+
typer.echo("Unrecognized answer (enter 'y' or 'n'): ", nl=False)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _cargar_marca(root: Path, nombre: str) -> Brand:
|
|
344
|
+
"""Load the explicitly named brand or exit with a readable error."""
|
|
345
|
+
try:
|
|
346
|
+
return cargar_brand(root, nombre)
|
|
347
|
+
except _EXCEPCIONES_MARCA as exc:
|
|
348
|
+
_fallar(str(exc))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _cargar_post_con_avisos(brand: Brand, slug: str, *, avisos: list[str] | None = None, source_link: bool = False) -> Post:
|
|
352
|
+
"""Load a post and display any loading warnings."""
|
|
353
|
+
with warnings.catch_warnings(record=True) as capturados:
|
|
354
|
+
warnings.simplefilter("always")
|
|
355
|
+
try:
|
|
356
|
+
post = cargar_post(brand, slug)
|
|
357
|
+
except _EXCEPCIONES_POST as exc:
|
|
358
|
+
for aviso in capturados:
|
|
359
|
+
typer.echo(f"WARNING: {aviso.message}")
|
|
360
|
+
typer.echo(str(exc))
|
|
361
|
+
raise typer.Exit(1)
|
|
362
|
+
|
|
363
|
+
for aviso in capturados:
|
|
364
|
+
if avisos is None:
|
|
365
|
+
typer.echo(f"WARNING: {aviso.message}")
|
|
366
|
+
else:
|
|
367
|
+
avisos.append(f"WARNING: {aviso.message}")
|
|
368
|
+
from socialctl.source_links import apply_source_mapping
|
|
369
|
+
try:
|
|
370
|
+
return apply_source_mapping(brand, post, required=source_link)
|
|
371
|
+
except ValueError as exc:
|
|
372
|
+
_fallar(str(exc))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _mostrar_comentario(result):
|
|
376
|
+
if result.publication_id:
|
|
377
|
+
typer.echo(f'Durable attempt: {result.publication_id}')
|
|
378
|
+
if result.first_comment_status:
|
|
379
|
+
typer.echo(f"First comment: {result.first_comment_status} (media: {result.platform_id or 'unconfirmed'})")
|
|
380
|
+
if result.first_comment_change_id:
|
|
381
|
+
typer.echo(f'Comment ChangeSet: {result.first_comment_change_id}')
|
|
382
|
+
for warning in result.warnings:
|
|
383
|
+
typer.echo(f"WARNING: {safe_error(warning)}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _publicar_impl(
|
|
387
|
+
post: Post,
|
|
388
|
+
brand: Brand,
|
|
389
|
+
*,
|
|
390
|
+
dry_run: bool,
|
|
391
|
+
yes: bool,
|
|
392
|
+
only: list[Platform] | None,
|
|
393
|
+
motivo_exclusion: str = "not requested with --only",
|
|
394
|
+
retry_guard: bool = False,
|
|
395
|
+
approval_fingerprint: str | None = None,
|
|
396
|
+
approval_stdin: bool = False,
|
|
397
|
+
report=None,
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Validate, preview and publish an already loaded post; shared by publish and retry."""
|
|
400
|
+
if only is not None:
|
|
401
|
+
destinos = [d for d in only if d in post.platforms]
|
|
402
|
+
else:
|
|
403
|
+
destinos = list(post.platforms)
|
|
404
|
+
|
|
405
|
+
errores = validar_todo(post, brand)
|
|
406
|
+
preview = render_preview(post, errores, brand=brand,
|
|
407
|
+
destinos=destinos if only is not None else None, motivo_exclusion=motivo_exclusion)
|
|
408
|
+
typer.echo(preview)
|
|
409
|
+
if report is not None:
|
|
410
|
+
report.preview = preview
|
|
411
|
+
|
|
412
|
+
if only is not None and not destinos:
|
|
413
|
+
_fallar(
|
|
414
|
+
"none of the requested platforms are in this post; there is nothing to publish."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
errores_bloqueantes = {p: es for p, es in errores.items() if p in destinos}
|
|
418
|
+
if any(errores_bloqueantes.values()):
|
|
419
|
+
if report is not None:
|
|
420
|
+
report.add_error("validation_failed")
|
|
421
|
+
typer.echo("\nNothing will be published: fix the problems above.")
|
|
422
|
+
raise typer.Exit(1)
|
|
423
|
+
|
|
424
|
+
def publication_digest(current_post, current_brand):
|
|
425
|
+
document = {"schema": "socialcli.publish.preview.v1", "slug": current_post.slug,
|
|
426
|
+
"platforms": {p.value: approval_hash(current_post, current_brand, p) for p in destinos}}
|
|
427
|
+
return hashlib.sha256(json.dumps(document, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
428
|
+
try:
|
|
429
|
+
displayed_digest = publication_digest(post, brand)
|
|
430
|
+
except ScheduleError as exc:
|
|
431
|
+
_fallar(str(exc))
|
|
432
|
+
typer.echo(f"Approval digest: {displayed_digest}")
|
|
433
|
+
if report is not None:
|
|
434
|
+
report.preview = preview + f"\nApproval digest: {displayed_digest}"
|
|
435
|
+
if dry_run:
|
|
436
|
+
if report is not None:
|
|
437
|
+
report.status = "preview"
|
|
438
|
+
typer.echo("\n--dry-run: nothing was published.")
|
|
439
|
+
raise typer.Exit(0)
|
|
440
|
+
|
|
441
|
+
exact_approval = approval_fingerprint is not None or approval_stdin
|
|
442
|
+
if exact_approval:
|
|
443
|
+
try:
|
|
444
|
+
supplied = read_approval(approval_fingerprint, approval_stdin=approval_stdin)
|
|
445
|
+
except ValueError as exc:
|
|
446
|
+
_fallar(str(exc))
|
|
447
|
+
if not hmac.compare_digest(supplied, displayed_digest):
|
|
448
|
+
_fallar("approval does not match the complete preview; run --dry-run and approve the current content")
|
|
449
|
+
if not yes and not exact_approval:
|
|
450
|
+
try:
|
|
451
|
+
confirmado = _confirmar(f"\nPublish on the {len(destinos)} platforms?")
|
|
452
|
+
except KeyboardInterrupt:
|
|
453
|
+
# Typer/Click convierten un Ctrl+C no atrapado en el código de
|
|
454
|
+
# salida 130 sin imprimir nada (ver `_main` en typer/core.py):
|
|
455
|
+
# correcto -no se publica nada-, pero deja al usuario sin
|
|
456
|
+
# confirmación visual, a diferencia de cualquier otro camino de
|
|
457
|
+
# cancelación de este comando, que sí dice "Cancelado". Aquí se
|
|
458
|
+
# informa y se relanza tal cual (nunca se traga la
|
|
459
|
+
# interrupción), para que typer siga terminando con 130 como
|
|
460
|
+
# siempre.
|
|
461
|
+
typer.echo("\nCanceled. Nothing was published.")
|
|
462
|
+
raise
|
|
463
|
+
if not confirmado:
|
|
464
|
+
if report is not None:
|
|
465
|
+
report.status = "cancelled"
|
|
466
|
+
typer.echo("Canceled. Nothing was published.")
|
|
467
|
+
raise typer.Exit(0)
|
|
468
|
+
|
|
469
|
+
from socialctl.source_links import verify_source_mapping
|
|
470
|
+
try:
|
|
471
|
+
current_brand = _cargar_marca(brand.raiz.parent, brand.nombre)
|
|
472
|
+
current_post = _cargar_post_con_avisos(current_brand, post.slug)
|
|
473
|
+
if (publication_digest(current_post, current_brand) != displayed_digest
|
|
474
|
+
or publication_digest(post, brand) != displayed_digest):
|
|
475
|
+
_fallar("content changed after the preview; run --dry-run again")
|
|
476
|
+
verify_source_mapping(brand, post)
|
|
477
|
+
except ValueError as exc:
|
|
478
|
+
_fallar(str(exc))
|
|
479
|
+
|
|
480
|
+
reporting = {}
|
|
481
|
+
if report is not None:
|
|
482
|
+
report.begin_write()
|
|
483
|
+
reporting = {'on_result': report.observe_safely}
|
|
484
|
+
resultados = publicar(post, brand, solo=destinos, on_progreso=_mostrar_progreso,
|
|
485
|
+
retry_guard=retry_guard, **reporting)
|
|
486
|
+
if report is not None:
|
|
487
|
+
for result in resultados:
|
|
488
|
+
report.observe_safely(result)
|
|
489
|
+
|
|
490
|
+
typer.echo("")
|
|
491
|
+
for r in resultados:
|
|
492
|
+
estado = describe_result_with_observation(r)
|
|
493
|
+
typer.echo(f"{r.platform.value}: {estado} {safe_error(r.url or r.error or '')}".rstrip())
|
|
494
|
+
_mostrar_comentario(r)
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
guardar_resultado(post, brand, resultados)
|
|
498
|
+
anexar_historial(post, brand, resultados)
|
|
499
|
+
if report is not None:
|
|
500
|
+
report.persistence = {"complete": True}
|
|
501
|
+
except PersistenciaError as exc:
|
|
502
|
+
if report is not None:
|
|
503
|
+
report.add_error("persistence_failed")
|
|
504
|
+
typer.echo(f"\n{safe_error(str(exc))}")
|
|
505
|
+
typer.echo(
|
|
506
|
+
'Publication WAS attempted for the results above; only saving them to disk failed.'
|
|
507
|
+
)
|
|
508
|
+
raise typer.Exit(1)
|
|
509
|
+
|
|
510
|
+
if any(r.status is PostStatus.ERROR for r in resultados):
|
|
511
|
+
raise typer.Exit(1)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@brand_app.command("new")
|
|
515
|
+
def brand_new(
|
|
516
|
+
nombre: str = typer.Argument(..., metavar="NAME", help="Name of the new brand."),
|
|
517
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Create a new brand directory from templates."""
|
|
520
|
+
try:
|
|
521
|
+
brand = crear_brand(root, nombre)
|
|
522
|
+
except (BrandYaExiste, NombreDeMarcaInvalido) as exc:
|
|
523
|
+
_fallar(str(exc))
|
|
524
|
+
|
|
525
|
+
typer.echo(f"Brand created at {brand.raiz}")
|
|
526
|
+
typer.echo("Next: complete brand.md and accounts.yml, then authenticate each platform.")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _auth_meta(brand: Brand, platform: Platform) -> None:
|
|
530
|
+
"""Authenticate Facebook or Instagram using the configured Page's token."""
|
|
531
|
+
typer.echo(AYUDA_META)
|
|
532
|
+
|
|
533
|
+
client_id = typer.prompt("Meta app ID")
|
|
534
|
+
client_secret = typer.prompt("Meta app secret", hide_input=True)
|
|
535
|
+
token_usuario = typer.prompt(
|
|
536
|
+
"Paste the USER token obtained from the Graph API Explorer",
|
|
537
|
+
hide_input=True,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
page_id = (brand.cuentas.get("facebook") or {}).get("page_id")
|
|
541
|
+
if not page_id:
|
|
542
|
+
_fallar(
|
|
543
|
+
f"facebook.page_id is missing in {brand.raiz / 'accounts.yml'}: configure it before authenticating Facebook or Instagram (both publish with that Page's token)."
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
typer.echo("Exchanging the token with Meta (this may take a few seconds)...")
|
|
547
|
+
try:
|
|
548
|
+
with httpx.Client(timeout=30) as client:
|
|
549
|
+
token_larga_duracion = intercambiar_token_meta(
|
|
550
|
+
client_id, client_secret, token_usuario, client
|
|
551
|
+
)
|
|
552
|
+
paginas = obtener_paginas_meta(token_larga_duracion, client)
|
|
553
|
+
except RuntimeError as exc:
|
|
554
|
+
_fallar(str(exc))
|
|
555
|
+
|
|
556
|
+
pagina = next((p for p in paginas if p.get("id") == page_id), None)
|
|
557
|
+
if pagina is None:
|
|
558
|
+
disponibles = ", ".join(
|
|
559
|
+
f"{p.get('name')!r} ({p.get('id')!r})" for p in paginas
|
|
560
|
+
) or 'none'
|
|
561
|
+
_fallar(
|
|
562
|
+
f'the Page with ID {page_id!r} (configured in accounts.yml) is not among the Pages administered by the user who authorized the token. Available Pages: {disponibles}.'
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
token_pagina = pagina.get("access_token")
|
|
566
|
+
if not isinstance(token_pagina, str) or not token_pagina:
|
|
567
|
+
_fallar("Meta did not return a valid page token for that Page.")
|
|
568
|
+
|
|
569
|
+
brand.guardar_secreto(platform, {"access_token": token_pagina})
|
|
570
|
+
typer.echo(f"Credentials for {platform.value} saved for {brand.nombre}.")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _auth_oauth_redireccion(
|
|
574
|
+
brand: Brand, platform: Platform, *, youtube_management: bool = False
|
|
575
|
+
) -> None:
|
|
576
|
+
"""Authenticate YouTube or TikTok through a localhost redirect."""
|
|
577
|
+
if platform is Platform.YOUTUBE:
|
|
578
|
+
client_id = typer.prompt("Google Cloud client_id")
|
|
579
|
+
client_secret = typer.prompt("Google Cloud client_secret", hide_input=True)
|
|
580
|
+
credenciales_app = {"client_id": client_id, "client_secret": client_secret}
|
|
581
|
+
identificador = client_id
|
|
582
|
+
else:
|
|
583
|
+
client_key = typer.prompt("TikTok client_key")
|
|
584
|
+
client_secret = typer.prompt("TikTok client_secret", hide_input=True)
|
|
585
|
+
credenciales_app = {"client_key": client_key, "client_secret": client_secret}
|
|
586
|
+
identificador = client_key
|
|
587
|
+
|
|
588
|
+
estado = generar_state()
|
|
589
|
+
code_verifier = generar_code_verifier()
|
|
590
|
+
|
|
591
|
+
url = construir_url_autorizacion(
|
|
592
|
+
platform,
|
|
593
|
+
identificador,
|
|
594
|
+
REDIRECT_URI_OAUTH,
|
|
595
|
+
state=estado,
|
|
596
|
+
code_verifier=code_verifier,
|
|
597
|
+
youtube_management=youtube_management,
|
|
598
|
+
)
|
|
599
|
+
typer.echo(f"Opening the browser to authorize {platform.value}...")
|
|
600
|
+
typer.echo(url)
|
|
601
|
+
webbrowser.open(url)
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
recibido = _esperar_codigo(PUERTO_CALLBACK, estado)
|
|
605
|
+
except RuntimeError as exc:
|
|
606
|
+
_fallar(str(exc))
|
|
607
|
+
|
|
608
|
+
if "error" in recibido:
|
|
609
|
+
detalle = recibido.get("error_description") or recibido["error"]
|
|
610
|
+
_fallar(f"{platform.value} rejected authorization: {detalle}")
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
verificar_state(estado, recibido.get("state"))
|
|
614
|
+
except EstadoInvalido as exc:
|
|
615
|
+
_fallar(str(exc))
|
|
616
|
+
|
|
617
|
+
codigo = recibido.get("code")
|
|
618
|
+
if not codigo:
|
|
619
|
+
_fallar(
|
|
620
|
+
'no authorization code was received (was the tab closed before login completed?). Try again.'
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
with httpx.Client(timeout=30) as client:
|
|
625
|
+
secreto = canjear_codigo(
|
|
626
|
+
platform, codigo, credenciales_app, REDIRECT_URI_OAUTH, client,
|
|
627
|
+
code_verifier=code_verifier,
|
|
628
|
+
)
|
|
629
|
+
except RuntimeError as exc:
|
|
630
|
+
_fallar(str(exc))
|
|
631
|
+
|
|
632
|
+
if platform is Platform.TIKTOK:
|
|
633
|
+
_guardar_open_id_tiktok(brand, secreto)
|
|
634
|
+
|
|
635
|
+
brand.guardar_secreto(platform, secreto)
|
|
636
|
+
typer.echo(f"Credentials for {platform.value} saved for {brand.nombre}.")
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _guardar_open_id_tiktok(brand: Brand, secreto: dict) -> None:
|
|
640
|
+
"""Save the newly exchanged TikTok open_id in accounts.yml when available."""
|
|
641
|
+
open_id = secreto.pop("open_id", None)
|
|
642
|
+
if not open_id:
|
|
643
|
+
typer.echo(
|
|
644
|
+
"Warning: TikTok did not return the account open_id. The access token was saved. Add tiktok.open_id manually in accounts.yml (see that section's comment or SETUP.md)."
|
|
645
|
+
)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
anterior = brand.guardar_open_id_tiktok(open_id)
|
|
650
|
+
except Exception:
|
|
651
|
+
typer.echo(
|
|
652
|
+
f"Warning: could not save open_id automatically in {brand.raiz / 'accounts.yml'} (check the structure of the 'tiktok:' section). The access token was saved; add tiktok.open_id manually."
|
|
653
|
+
)
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
if anterior is not None:
|
|
657
|
+
typer.echo(
|
|
658
|
+
f"Warning: TikTok open_id in accounts.yml changes from '{anterior}' to '{open_id}'."
|
|
659
|
+
)
|
|
660
|
+
else:
|
|
661
|
+
typer.echo(f"TikTok open_id saved in accounts.yml: {open_id}")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@app.command()
|
|
665
|
+
def auth(
|
|
666
|
+
red: str = typer.Argument(..., metavar="PLATFORM", help="Platform to authenticate, or status."),
|
|
667
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand to authenticate."),
|
|
668
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
669
|
+
management: bool = typer.Option(
|
|
670
|
+
False,
|
|
671
|
+
"--management",
|
|
672
|
+
help="YouTube: also request youtube.force-ssl to edit metadata.",
|
|
673
|
+
),
|
|
674
|
+
status_platform: Platform | None = typer.Option(None, "--platform", help="Explicit platform for auth status."),
|
|
675
|
+
json_output: bool = typer.Option(False, "--json", help="auth status: versioned output."),
|
|
676
|
+
no_browser: bool = typer.Option(False, '--no-browser', help='Print the SaaS connection link for the user without opening it.'),
|
|
677
|
+
analytics: bool = typer.Option(False, '--analytics', help='Request analytics access through the SaaS connection flow.'),
|
|
678
|
+
timeout: float = typer.Option(600, '--timeout', min=1, max=600, help='Maximum SaaS browser connection wait in seconds.'),
|
|
679
|
+
replace_independent: bool = typer.Option(False, '--replace-independent', help='Permit replacing independent credentials with browser approval.'),
|
|
680
|
+
independent: bool = typer.Option(False, '--independent', help='Legacy developer-owned app setup; requires an interactive terminal and your own app credentials.'),
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Connect through SocialCli after account login; use connect for the full flow."""
|
|
683
|
+
if red == "status":
|
|
684
|
+
from socialctl.inventory_cli import show_auth_status
|
|
685
|
+
show_auth_status(root, brand_nombre, status_platform, json_output)
|
|
686
|
+
return
|
|
687
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
platform = Platform(red)
|
|
691
|
+
except ValueError:
|
|
692
|
+
validas = ", ".join(p.value for p in Platform)
|
|
693
|
+
_fallar(f'unknown platform: {red!r}. Valid platforms: {validas}')
|
|
694
|
+
|
|
695
|
+
from socialctl.connections.cli import acquire_brand_lock, interactive_terminal
|
|
696
|
+
from socialctl.connections.account_cli import metadata as account_metadata, connect_saas
|
|
697
|
+
try:
|
|
698
|
+
lock_fd = acquire_brand_lock(brand)
|
|
699
|
+
except ValueError as exc:
|
|
700
|
+
_fallar(str(exc))
|
|
701
|
+
try:
|
|
702
|
+
if account_metadata(brand):
|
|
703
|
+
if independent:
|
|
704
|
+
_fallar('This workspace uses SocialCli SaaS. Use socialcli connect; developer credentials are not required.')
|
|
705
|
+
if platform is Platform.TIKTOK and (management or analytics):
|
|
706
|
+
_fallar('TikTok does not accept --management or --analytics in this connection flow.')
|
|
707
|
+
connect_saas(brand, platform, no_browser=no_browser or not interactive_terminal(),
|
|
708
|
+
management=management, analytics=analytics, timeout=timeout,
|
|
709
|
+
replace_independent=replace_independent)
|
|
710
|
+
return
|
|
711
|
+
if brand.leer_secreto(platform).get("auth_mode") == "broker":
|
|
712
|
+
if not independent:
|
|
713
|
+
_fallar('Existing shared credentials were preserved. Use socialcli account login --brand NAME --no-browser, '
|
|
714
|
+
'then socialcli account migrate ' + platform.value + ' --brand NAME for an eligible legacy connection, '
|
|
715
|
+
'or account attach for an existing SaaS connection. Do not repeat OAuth or replace credentials silently.')
|
|
716
|
+
_fallar("a shared connection already exists; run socialcli disconnect for this platform before independent-app auth")
|
|
717
|
+
|
|
718
|
+
if not independent:
|
|
719
|
+
_fallar('Sign in first with socialcli account login --brand NAME --no-browser, then run socialcli connect '
|
|
720
|
+
+ platform.value + ' --brand NAME --no-browser. SocialCli users do not need developer app credentials. '
|
|
721
|
+
'Legacy developer-owned app setup requires explicit --independent.')
|
|
722
|
+
if not interactive_terminal():
|
|
723
|
+
_fallar('Independent app setup requires an interactive terminal. For SocialCli SaaS, use account login followed by connect --no-browser.')
|
|
724
|
+
if no_browser or analytics or replace_independent:
|
|
725
|
+
_fallar('--no-browser, --analytics and --replace-independent belong to the SaaS connection flow.')
|
|
726
|
+
|
|
727
|
+
if management and platform is not Platform.YOUTUBE:
|
|
728
|
+
_fallar("--management is only available for YouTube")
|
|
729
|
+
|
|
730
|
+
if platform in (Platform.FACEBOOK, Platform.INSTAGRAM):
|
|
731
|
+
_auth_meta(brand, platform)
|
|
732
|
+
else:
|
|
733
|
+
_auth_oauth_redireccion(
|
|
734
|
+
brand, platform, youtube_management=management
|
|
735
|
+
)
|
|
736
|
+
except typer.Exit:
|
|
737
|
+
raise
|
|
738
|
+
except (KeyboardInterrupt, typer.Abort):
|
|
739
|
+
_fallar('Connection cancelled.')
|
|
740
|
+
except ValueError as exc:
|
|
741
|
+
_fallar(str(exc))
|
|
742
|
+
except Exception:
|
|
743
|
+
_fallar('Could not connect safely; existing credentials were preserved.')
|
|
744
|
+
finally:
|
|
745
|
+
import os
|
|
746
|
+
os.close(lock_fd)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@app.command()
|
|
750
|
+
def publish(
|
|
751
|
+
slug: str,
|
|
752
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand to publish."),
|
|
753
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
754
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show the preview without publishing."),
|
|
755
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
|
|
756
|
+
only: list[str] = typer.Option(None, "--only", help="Publish only on these platforms."),
|
|
757
|
+
source_link: bool = typer.Option(False, "--source-link", help="Require this exact post in source-videos.yml before previewing."),
|
|
758
|
+
approval_fingerprint: str | None = typer.Option(None, "--approval-fingerprint", "--approval", "--approval-digest", help="Exact digest of the approved complete preview."),
|
|
759
|
+
approval_stdin: bool = typer.Option(False, "--approval-stdin", help="Explicitly read one approved digest line from stdin."),
|
|
760
|
+
json_output: bool = typer.Option(False, "--json", help="One JSON result; complete preview and approval UI on stderr."),
|
|
761
|
+
) -> None:
|
|
762
|
+
"""Validate every platform, display the complete preview, then publish approved content."""
|
|
763
|
+
with PublishOutput(json_output, command="publish", brand=brand_nombre, slug=slug) as report:
|
|
764
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
765
|
+
post = _cargar_post_con_avisos(brand, slug, source_link=source_link)
|
|
766
|
+
|
|
767
|
+
destinos: list[Platform] | None = None
|
|
768
|
+
if only:
|
|
769
|
+
try:
|
|
770
|
+
destinos = [Platform(r) for r in only]
|
|
771
|
+
except ValueError:
|
|
772
|
+
validas = ", ".join(p.value for p in Platform)
|
|
773
|
+
_fallar(f"--only contains an unknown platform. Valid platforms: {validas}")
|
|
774
|
+
|
|
775
|
+
_publicar_impl(post, brand, dry_run=dry_run, yes=yes, only=destinos, report=report, approval_fingerprint=approval_fingerprint, approval_stdin=approval_stdin)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@app.command("schedule")
|
|
779
|
+
def schedule(
|
|
780
|
+
slug: str,
|
|
781
|
+
at: str = typer.Option(..., "--at", help="ISO 8601 timestamp with timezone."),
|
|
782
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand to schedule."),
|
|
783
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
784
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show the preview without saving the queue."),
|
|
785
|
+
yes: bool = typer.Option(False, "--yes", help="Approve the queue without another prompt."),
|
|
786
|
+
only: list[str] = typer.Option(None, "--only", help="Schedule only these platforms."),
|
|
787
|
+
approval_digest: str | None = typer.Option(None, "--approval-fingerprint", "--approval", "--approval-digest", help="Digest of the previously approved preview."),
|
|
788
|
+
approval_stdin: bool = typer.Option(False, "--approval-stdin", help="Explicitly read one approved digest line from stdin."),
|
|
789
|
+
preview_json: bool = typer.Option(False, "--preview-json", hidden=True),
|
|
790
|
+
) -> None:
|
|
791
|
+
"""Validate and add a Facebook/Instagram post to the local queue."""
|
|
792
|
+
if preview_json and (not dry_run or yes or approval_digest or approval_stdin):
|
|
793
|
+
_fallar("--preview-json requires --dry-run and does not support approval")
|
|
794
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
795
|
+
if is_remote(brand.raiz):
|
|
796
|
+
_fallar(REMOTE_NOTICE)
|
|
797
|
+
preview_warnings: list[str] = []
|
|
798
|
+
post = _cargar_post_con_avisos(brand, slug, avisos=preview_warnings if preview_json else None)
|
|
799
|
+
try:
|
|
800
|
+
scheduled_at = parse_scheduled_at(at)
|
|
801
|
+
except ScheduleError as exc:
|
|
802
|
+
_fallar(str(exc))
|
|
803
|
+
if only:
|
|
804
|
+
try:
|
|
805
|
+
destinos = [Platform(item) for item in only]
|
|
806
|
+
except ValueError:
|
|
807
|
+
_fallar("--only allows youtube, facebook, instagram or tiktok")
|
|
808
|
+
else:
|
|
809
|
+
destinos = list(post.platforms)
|
|
810
|
+
destinos = [p for p in destinos if p in (Platform.FACEBOOK, Platform.INSTAGRAM)]
|
|
811
|
+
if not destinos:
|
|
812
|
+
_fallar("schedule only supports Facebook and Instagram, and the post contains neither")
|
|
813
|
+
if any(p not in post.platforms for p in destinos):
|
|
814
|
+
_fallar("the post does not contain all requested platforms")
|
|
815
|
+
try:
|
|
816
|
+
displayed_hashes = {p.value: approval_hash(post, brand, p) for p in destinos}
|
|
817
|
+
displayed_digest = schedule_digest(post.slug, displayed_hashes, scheduled_at)
|
|
818
|
+
except ScheduleError as exc:
|
|
819
|
+
_fallar(str(exc))
|
|
820
|
+
errores = validar_todo(post, brand)
|
|
821
|
+
preview = render_preview(post, errores, brand=brand, destinos=destinos, motivo_exclusion="not requested for this schedule")
|
|
822
|
+
bloqueantes = [p for p in destinos if errores.get(p)]
|
|
823
|
+
if preview_json:
|
|
824
|
+
from socialctl.schedule_remote import SchedulePreview
|
|
825
|
+
try:
|
|
826
|
+
response = SchedulePreview(protocol="socialctl.schedule-preview.v1",
|
|
827
|
+
valid=not bloqueantes, preview="\n".join([*preview_warnings, preview]),
|
|
828
|
+
digest=displayed_digest, scheduled_at=scheduled_at.isoformat())
|
|
829
|
+
except ValueError:
|
|
830
|
+
_fallar("unsafe preview; review the content locally before continuing")
|
|
831
|
+
typer.echo(response.model_dump_json())
|
|
832
|
+
raise typer.Exit(1 if bloqueantes else 0)
|
|
833
|
+
typer.echo(preview)
|
|
834
|
+
typer.echo(f"Approval digest: {displayed_digest}")
|
|
835
|
+
if bloqueantes:
|
|
836
|
+
typer.echo("\nNothing will be scheduled: fix the problems above.")
|
|
837
|
+
raise typer.Exit(1)
|
|
838
|
+
if dry_run:
|
|
839
|
+
typer.echo(f"\n--dry-run: would schedule for {scheduled_at.isoformat()}.")
|
|
840
|
+
raise typer.Exit(0)
|
|
841
|
+
if approval_digest is not None or approval_stdin:
|
|
842
|
+
try:
|
|
843
|
+
approval_digest = read_approval(approval_digest, approval_stdin=approval_stdin)
|
|
844
|
+
except ValueError as exc:
|
|
845
|
+
_fallar(str(exc))
|
|
846
|
+
if approval_digest is not None and not hmac.compare_digest(approval_digest, displayed_digest):
|
|
847
|
+
_fallar("digest does not match the approved preview; run --dry-run and approve the current content")
|
|
848
|
+
if not yes and approval_digest is None and not _confirmar(f"\nSave {len(destinos)} schedule(s) to the local queue?"):
|
|
849
|
+
typer.echo("Canceled. No schedule was saved.")
|
|
850
|
+
raise typer.Exit(0)
|
|
851
|
+
# Re-read disk/account changes as well as media bytes after confirmation.
|
|
852
|
+
current_brand = _cargar_marca(root, brand_nombre)
|
|
853
|
+
current_post = _cargar_post_con_avisos(current_brand, slug)
|
|
854
|
+
try:
|
|
855
|
+
if ({p.value: approval_hash(current_post, current_brand, p) for p in destinos} != displayed_hashes
|
|
856
|
+
or {p.value: approval_hash(post, brand, p) for p in destinos} != displayed_hashes):
|
|
857
|
+
_fallar("content changed after the preview; run --dry-run again")
|
|
858
|
+
except ScheduleError as exc:
|
|
859
|
+
_fallar(str(exc))
|
|
860
|
+
store = ScheduleStore(brand.raiz)
|
|
861
|
+
now = now_utc()
|
|
862
|
+
nuevas = []
|
|
863
|
+
for platform in destinos:
|
|
864
|
+
entry = ScheduleEntry(
|
|
865
|
+
id=f"{post.slug}/{platform.value}", brand=brand.nombre, slug=post.slug,
|
|
866
|
+
platform=platform.value, scheduled_at=scheduled_at, status="approved",
|
|
867
|
+
created_at=now, updated_at=now,
|
|
868
|
+
content_hash=displayed_hashes[platform.value],
|
|
869
|
+
)
|
|
870
|
+
nuevas.append(entry)
|
|
871
|
+
try:
|
|
872
|
+
store.add_many(nuevas)
|
|
873
|
+
except ScheduleError as exc:
|
|
874
|
+
_fallar(str(exc))
|
|
875
|
+
for entry in nuevas:
|
|
876
|
+
typer.echo(f"Scheduled: {entry.id} → {scheduled_at.isoformat()}")
|
|
877
|
+
typer.echo(f"Queue updated: {len(destinos)} entry/entries.")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@app.command("schedule-status")
|
|
881
|
+
def schedule_status(
|
|
882
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand whose queue to inspect."),
|
|
883
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
884
|
+
) -> None:
|
|
885
|
+
"""Display the brand's scheduling queue."""
|
|
886
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
887
|
+
if is_remote(brand.raiz):
|
|
888
|
+
typer.echo(REMOTE_NOTICE)
|
|
889
|
+
return
|
|
890
|
+
entries = ScheduleStore(brand.raiz).load()
|
|
891
|
+
if not entries:
|
|
892
|
+
typer.echo("The queue is empty.")
|
|
893
|
+
return
|
|
894
|
+
for entry in entries:
|
|
895
|
+
error = " — error recorded (details omitted)" if entry.last_error else ""
|
|
896
|
+
typer.echo(f"{entry.id} | {entry.scheduled_at.isoformat()} | {entry.status} | attempts={entry.attempts}{error}")
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@app.command("schedule-batch")
|
|
900
|
+
def schedule_batch(
|
|
901
|
+
fichero: Path,
|
|
902
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand to schedule."),
|
|
903
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
904
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show previews without saving the queue."),
|
|
905
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the prompt after showing previews."),
|
|
906
|
+
) -> None:
|
|
907
|
+
"""Schedule multiple posts atomically from a YAML batch."""
|
|
908
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
909
|
+
if is_remote(brand.raiz):
|
|
910
|
+
_fallar(REMOTE_NOTICE)
|
|
911
|
+
try:
|
|
912
|
+
datos = yaml.safe_load(fichero.read_text(encoding="utf-8"))
|
|
913
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
914
|
+
_fallar(f"could not read the batch {fichero}: {exc}")
|
|
915
|
+
if not isinstance(datos, list) or not datos:
|
|
916
|
+
_fallar("batch must be a nonempty YAML list")
|
|
917
|
+
nuevas: list[ScheduleEntry] = []
|
|
918
|
+
previews: list[
|
|
919
|
+
tuple[int, Post, list[Platform], datetime, dict[Platform, list]]
|
|
920
|
+
] = []
|
|
921
|
+
aprobaciones: list[tuple[Post, Platform, str]] = []
|
|
922
|
+
problemas: list[str] = []
|
|
923
|
+
now = now_utc()
|
|
924
|
+
for index, item in enumerate(datos, start=1):
|
|
925
|
+
if not isinstance(item, dict) or not isinstance(item.get("slug"), str) or not isinstance(item.get("at"), str):
|
|
926
|
+
_fallar(f"entry {index} in the batch requires 'slug' and 'at'")
|
|
927
|
+
try:
|
|
928
|
+
at = parse_scheduled_at(item["at"])
|
|
929
|
+
post = _cargar_post_con_avisos(brand, item["slug"])
|
|
930
|
+
except ScheduleError as exc:
|
|
931
|
+
_fallar(f"entry {index}: {exc}")
|
|
932
|
+
only = item.get("only", ["facebook", "instagram"])
|
|
933
|
+
if not isinstance(only, list):
|
|
934
|
+
_fallar(f"entry {index}: 'only' must be a list")
|
|
935
|
+
try:
|
|
936
|
+
plataformas = [Platform(value) for value in only]
|
|
937
|
+
except ValueError:
|
|
938
|
+
_fallar(f"entry {index}: unknown platform in 'only'")
|
|
939
|
+
plataformas = [p for p in plataformas if p in (Platform.FACEBOOK, Platform.INSTAGRAM) and p in post.platforms]
|
|
940
|
+
errores = validar_todo(post, brand)
|
|
941
|
+
if any(errores.get(p) for p in plataformas):
|
|
942
|
+
problemas.append(
|
|
943
|
+
f"entry {index} ({post.slug}): validation problems found"
|
|
944
|
+
)
|
|
945
|
+
previews.append((index, post, plataformas, at, errores))
|
|
946
|
+
for platform in plataformas:
|
|
947
|
+
try:
|
|
948
|
+
content_hash = approval_hash(post, brand, platform)
|
|
949
|
+
except ScheduleError as exc:
|
|
950
|
+
_fallar(f"entry {index} ({post.slug}/{platform.value}): {exc}")
|
|
951
|
+
nuevas.append(ScheduleEntry(
|
|
952
|
+
id=f"{post.slug}/{platform.value}", brand=brand.nombre,
|
|
953
|
+
slug=post.slug, platform=platform.value, scheduled_at=at,
|
|
954
|
+
status="approved", created_at=now, updated_at=now,
|
|
955
|
+
content_hash=content_hash,
|
|
956
|
+
))
|
|
957
|
+
aprobaciones.append((post, platform, content_hash))
|
|
958
|
+
for index, post, plataformas, at, errores in previews:
|
|
959
|
+
typer.echo(
|
|
960
|
+
f"\n=== Entry {index}: {post.slug} → {at.isoformat()} ==="
|
|
961
|
+
)
|
|
962
|
+
typer.echo(
|
|
963
|
+
render_preview(
|
|
964
|
+
post,
|
|
965
|
+
errores,
|
|
966
|
+
brand=brand,
|
|
967
|
+
destinos=plataformas,
|
|
968
|
+
motivo_exclusion='not included in this batch entry',
|
|
969
|
+
)
|
|
970
|
+
)
|
|
971
|
+
typer.echo(f"Valid entries: {len(nuevas)}")
|
|
972
|
+
for entry in sorted(nuevas, key=lambda value: value.scheduled_at):
|
|
973
|
+
typer.echo(f" {entry.id} → {entry.scheduled_at.isoformat()}")
|
|
974
|
+
if problemas:
|
|
975
|
+
typer.echo("\nNothing will be scheduled: fix the problems above.")
|
|
976
|
+
_fallar("; ".join(problemas))
|
|
977
|
+
if dry_run:
|
|
978
|
+
typer.echo("--dry-run: the queue was not saved.")
|
|
979
|
+
raise typer.Exit(0)
|
|
980
|
+
if not yes and not _confirmar("Save this batch to the local queue?"):
|
|
981
|
+
typer.echo("Canceled. No schedule was saved.")
|
|
982
|
+
raise typer.Exit(0)
|
|
983
|
+
# Las huellas se toman antes de renderizar. Se vuelven a calcular tras la
|
|
984
|
+
# aprobación para que ni ``--yes`` ni una mutación concurrente de la media
|
|
985
|
+
# puedan convertir en aprobada una carga distinta de la que se mostró.
|
|
986
|
+
for post, platform, displayed_hash in aprobaciones:
|
|
987
|
+
try:
|
|
988
|
+
current_hash = approval_hash(post, brand, platform)
|
|
989
|
+
except ScheduleError as exc:
|
|
990
|
+
_fallar(
|
|
991
|
+
f"{post.slug}/{platform.value}: could not reverify "
|
|
992
|
+
f"approval after the preview: {exc}"
|
|
993
|
+
)
|
|
994
|
+
if not hmac.compare_digest(current_hash, displayed_hash):
|
|
995
|
+
_fallar(
|
|
996
|
+
f"{post.slug}/{platform.value}: content, media or account "
|
|
997
|
+
"changed after the preview; run --dry-run again and approve "
|
|
998
|
+
"the new content"
|
|
999
|
+
)
|
|
1000
|
+
try:
|
|
1001
|
+
ScheduleStore(brand.raiz).add_many(nuevas)
|
|
1002
|
+
except ScheduleError as exc:
|
|
1003
|
+
_fallar(str(exc))
|
|
1004
|
+
typer.echo(f"Queue updated: {len(nuevas)} entry/entries.")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
@app.command("schedule-cancel")
|
|
1008
|
+
def schedule_cancel(
|
|
1009
|
+
entry_id: str,
|
|
1010
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand whose schedule to cancel."),
|
|
1011
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
1012
|
+
) -> None:
|
|
1013
|
+
"""Cancel a schedule that has not run."""
|
|
1014
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1015
|
+
store = ScheduleStore(brand.raiz)
|
|
1016
|
+
try:
|
|
1017
|
+
store.cancel(entry_id, now_utc())
|
|
1018
|
+
except ScheduleError as exc:
|
|
1019
|
+
_fallar(str(exc))
|
|
1020
|
+
typer.echo(f"Canceled: {entry_id}")
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@app.command("schedule-reschedule")
|
|
1024
|
+
def schedule_reschedule(
|
|
1025
|
+
entry_id: str,
|
|
1026
|
+
at: str = typer.Option(..., "--at", help="New ISO 8601 timestamp with timezone."),
|
|
1027
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand whose schedule to change."),
|
|
1028
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
1029
|
+
) -> None:
|
|
1030
|
+
"""Change the time of a pending schedule."""
|
|
1031
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1032
|
+
try:
|
|
1033
|
+
scheduled_at = parse_scheduled_at(at)
|
|
1034
|
+
store = ScheduleStore(brand.raiz)
|
|
1035
|
+
store.reschedule(entry_id, scheduled_at, now_utc())
|
|
1036
|
+
except ScheduleError as exc:
|
|
1037
|
+
_fallar(str(exc))
|
|
1038
|
+
typer.echo(f"Rescheduled: {entry_id} → {scheduled_at.isoformat()}")
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
@app.command("run-due")
|
|
1042
|
+
def run_due(
|
|
1043
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand whose queue to run."),
|
|
1044
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
1045
|
+
) -> None:
|
|
1046
|
+
"""Run approved publications whose scheduled time has arrived, once."""
|
|
1047
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1048
|
+
store = ScheduleStore(brand.raiz)
|
|
1049
|
+
from socialctl.executor import heartbeat
|
|
1050
|
+
try:
|
|
1051
|
+
with store.executor_lock():
|
|
1052
|
+
heartbeat(store, "started", now_utc())
|
|
1053
|
+
try:
|
|
1054
|
+
_run_due_locked(brand, store)
|
|
1055
|
+
except BaseException:
|
|
1056
|
+
try:
|
|
1057
|
+
heartbeat(store, "error", now_utc())
|
|
1058
|
+
except ScheduleError:
|
|
1059
|
+
pass # Preserve original failure; previous started heartbeat becomes stale.
|
|
1060
|
+
raise
|
|
1061
|
+
heartbeat(store, "finished", now_utc())
|
|
1062
|
+
except ScheduleError as exc:
|
|
1063
|
+
_fallar(str(exc))
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _run_due_locked(brand: Brand, store: ScheduleStore) -> None:
|
|
1067
|
+
"""Process the queue while holding its executor lock."""
|
|
1068
|
+
store.assert_ready()
|
|
1069
|
+
recuperadas = store.recover_stale()
|
|
1070
|
+
if recuperadas:
|
|
1071
|
+
typer.echo(
|
|
1072
|
+
f"Isolated {recuperadas} interrupted execution(s) for manual review."
|
|
1073
|
+
)
|
|
1074
|
+
from socialctl.executor import admitted_groups
|
|
1075
|
+
due = (entry for group in admitted_groups(store, now_utc(), clock=now_utc) for entry in group)
|
|
1076
|
+
for candidate in due:
|
|
1077
|
+
entry = store.claim_due(candidate.id, now_utc(), expected=candidate)
|
|
1078
|
+
if entry is None:
|
|
1079
|
+
continue
|
|
1080
|
+
try:
|
|
1081
|
+
platform = Platform(entry.platform)
|
|
1082
|
+
post = _cargar_post_con_avisos(brand, entry.slug)
|
|
1083
|
+
review_reason = approval_review_reason(
|
|
1084
|
+
entry.content_hash, post, brand, platform
|
|
1085
|
+
)
|
|
1086
|
+
if review_reason is not None:
|
|
1087
|
+
store.transition(
|
|
1088
|
+
entry.id,
|
|
1089
|
+
{"running"},
|
|
1090
|
+
status="manual_review",
|
|
1091
|
+
moment=now_utc(),
|
|
1092
|
+
last_error=review_reason,
|
|
1093
|
+
)
|
|
1094
|
+
typer.echo(f"{entry.id}: manual_review — {review_reason}")
|
|
1095
|
+
continue
|
|
1096
|
+
pp = post.platforms[platform]
|
|
1097
|
+
legacy_approved_platforms = frozenset()
|
|
1098
|
+
if pp.content_origin is None and pp.source_video_id is None:
|
|
1099
|
+
# The stored v2 hash above authorizes only this unchanged entry.
|
|
1100
|
+
legacy_approved_platforms = frozenset({platform})
|
|
1101
|
+
typer.echo(f"{entry.id}: legacy entry: source unverified")
|
|
1102
|
+
errores = validar_todo(
|
|
1103
|
+
post, brand, legacy_approved_platforms=legacy_approved_platforms
|
|
1104
|
+
).get(platform, [])
|
|
1105
|
+
if errores:
|
|
1106
|
+
error = "; ".join(e.motivo for e in errores)
|
|
1107
|
+
store.transition(
|
|
1108
|
+
entry.id,
|
|
1109
|
+
{"running"},
|
|
1110
|
+
status="error",
|
|
1111
|
+
moment=now_utc(),
|
|
1112
|
+
last_error=error,
|
|
1113
|
+
)
|
|
1114
|
+
typer.echo(f"{entry.id}: validation error — {error}")
|
|
1115
|
+
continue
|
|
1116
|
+
if platform is Platform.INSTAGRAM:
|
|
1117
|
+
from socialctl.hosted_media import verify_scheduled_instagram
|
|
1118
|
+
verify_scheduled_instagram(post, brand, entry=entry)
|
|
1119
|
+
typer.echo(f"Publishing {entry.id}...")
|
|
1120
|
+
def persist_media(result):
|
|
1121
|
+
store.transition(entry.id, {"running"}, status="running", moment=now_utc(),
|
|
1122
|
+
platform_id=result.platform_id)
|
|
1123
|
+
guardar_resultado(post, brand, [result])
|
|
1124
|
+
|
|
1125
|
+
from socialctl.scheduler import occurrence_id
|
|
1126
|
+
occurrence = occurrence_id(brand.raiz, entry)
|
|
1127
|
+
resultados = publicar(post, brand, solo=[platform], on_progreso=_mostrar_progreso,
|
|
1128
|
+
on_media_result=persist_media, occurrence_ids={platform: occurrence},
|
|
1129
|
+
approval_provenance=f"schedule:{entry.id}:{entry.content_hash}",
|
|
1130
|
+
legacy_approved_platforms=legacy_approved_platforms)
|
|
1131
|
+
resultado = resultados[0]
|
|
1132
|
+
store.transition(
|
|
1133
|
+
entry.id,
|
|
1134
|
+
{"running"},
|
|
1135
|
+
status="running",
|
|
1136
|
+
moment=now_utc(),
|
|
1137
|
+
platform_id=resultado.platform_id,
|
|
1138
|
+
)
|
|
1139
|
+
guardar_resultado(post, brand, resultados)
|
|
1140
|
+
anexar_historial(post, brand, resultados)
|
|
1141
|
+
status = "published" if resultado.status is PostStatus.PUBLICADO else ("manual_review" if resultado.riesgo_duplicado else "error")
|
|
1142
|
+
store.transition(
|
|
1143
|
+
entry.id,
|
|
1144
|
+
{"running"},
|
|
1145
|
+
status=status,
|
|
1146
|
+
moment=now_utc(),
|
|
1147
|
+
last_error=resultado.error,
|
|
1148
|
+
platform_id=resultado.platform_id,
|
|
1149
|
+
)
|
|
1150
|
+
typer.echo(f"{entry.id}: {status}")
|
|
1151
|
+
_mostrar_comentario(resultado)
|
|
1152
|
+
except Exception as exc:
|
|
1153
|
+
error = (
|
|
1154
|
+
f"execution interrupted ({type(exc).__name__}): confirm the "
|
|
1155
|
+
"remote result before retrying"
|
|
1156
|
+
)
|
|
1157
|
+
store.transition(
|
|
1158
|
+
entry.id,
|
|
1159
|
+
{"running"},
|
|
1160
|
+
status="manual_review",
|
|
1161
|
+
moment=now_utc(),
|
|
1162
|
+
last_error=error,
|
|
1163
|
+
)
|
|
1164
|
+
typer.echo(f"{entry.id}: manual_review — {error}")
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
@app.command("schedule-health")
|
|
1168
|
+
def schedule_health(
|
|
1169
|
+
brand_nombre: str = typer.Option(..., "--brand"),
|
|
1170
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO),
|
|
1171
|
+
) -> None:
|
|
1172
|
+
"""Display durable executor health; stale means no heartbeat for more than three minutes."""
|
|
1173
|
+
from socialctl.executor import health
|
|
1174
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1175
|
+
if is_remote(brand.raiz):
|
|
1176
|
+
typer.echo(REMOTE_NOTICE)
|
|
1177
|
+
return
|
|
1178
|
+
try:
|
|
1179
|
+
typer.echo(json.dumps(health(ScheduleStore(brand.raiz), now_utc()), ensure_ascii=False))
|
|
1180
|
+
except ScheduleError as exc:
|
|
1181
|
+
_fallar(str(exc))
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
@app.command("schedule-install")
|
|
1185
|
+
def schedule_install(
|
|
1186
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand to install the agent for."),
|
|
1187
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
1188
|
+
) -> None:
|
|
1189
|
+
"""Install the local queue executor using macOS launchd or Linux systemd user timers."""
|
|
1190
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1191
|
+
try:
|
|
1192
|
+
ScheduleStore(brand.raiz).assert_ready()
|
|
1193
|
+
except ScheduleError as exc:
|
|
1194
|
+
_fallar(str(exc))
|
|
1195
|
+
if sys.platform.startswith('linux'):
|
|
1196
|
+
from socialctl.scheduler_systemd import install, SupervisorError
|
|
1197
|
+
try:
|
|
1198
|
+
supervisor = install(brand.nombre,root)
|
|
1199
|
+
except (SupervisorError,OSError) as exc:
|
|
1200
|
+
_fallar(str(exc) if isinstance(exc,SupervisorError) else 'Could not save Linux scheduler units')
|
|
1201
|
+
typer.echo(f"Linux user timer installed and active: {supervisor['unit']}.timer")
|
|
1202
|
+
typer.echo('Original media stays on this customer-owned machine; keep it powered on and connected.')
|
|
1203
|
+
if supervisor['survives_logout'] is not True:
|
|
1204
|
+
typer.echo(f'Logout persistence is unverified. The operator can enable it with loginctl enable-linger {os.getuid()}; then run schedule-doctor.')
|
|
1205
|
+
return
|
|
1206
|
+
from socialctl.scheduler_launchd import install, SupervisorError
|
|
1207
|
+
try:
|
|
1208
|
+
supervisor = install(brand.nombre, root)
|
|
1209
|
+
except (SupervisorError, OSError) as exc:
|
|
1210
|
+
_fallar(str(exc) if isinstance(exc, SupervisorError) else 'Could not save macOS scheduler configuration.')
|
|
1211
|
+
typer.echo(f"Agent installed and verified: {supervisor['unit']}")
|
|
1212
|
+
typer.echo('launchd runs the queue every 60 seconds while this user is logged in; keep the Mac powered on, awake and connected.')
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
@app.command('schedule-doctor')
|
|
1216
|
+
def schedule_doctor(
|
|
1217
|
+
brand_nombre: str = typer.Option(..., '--brand'),
|
|
1218
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO),
|
|
1219
|
+
json_output: bool = typer.Option(False, '--json'),
|
|
1220
|
+
):
|
|
1221
|
+
"""Check local executor evidence. A heartbeat cannot prove future availability."""
|
|
1222
|
+
from socialctl.executor import health
|
|
1223
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1224
|
+
if is_remote(brand.raiz):
|
|
1225
|
+
result = {'version': 1, 'status': 'attention_required', 'server_media_storage': False,
|
|
1226
|
+
'persistent_supervision': 'unverified', 'unattended_delivery_guaranteed': False,
|
|
1227
|
+
'error': {'code': 'remote_executor_unverified', 'action': 'user_action',
|
|
1228
|
+
'message': 'Inspect the configured customer-owned remote executor; this command does not transfer media.'}}
|
|
1229
|
+
else:
|
|
1230
|
+
try:
|
|
1231
|
+
observation = health(ScheduleStore(brand.raiz), now_utc())
|
|
1232
|
+
except ScheduleError:
|
|
1233
|
+
observation = {'state': 'unreadable', 'stale': True}
|
|
1234
|
+
if observation.get('state') == 'error':
|
|
1235
|
+
diagnostic = {'code': 'executor_error', 'action': 'user_action',
|
|
1236
|
+
'message': 'The executor reported an error; inspect its log before relying on the queue.'}
|
|
1237
|
+
elif observation['stale']:
|
|
1238
|
+
diagnostic = {'code': 'executor_unverified', 'action': 'user_action',
|
|
1239
|
+
'message': 'No recent executor evidence.'}
|
|
1240
|
+
else:
|
|
1241
|
+
diagnostic = {'code': 'supervision_unverified', 'action': 'user_action',
|
|
1242
|
+
'message': 'A recent execution was observed; persistent supervision still needs verification.'}
|
|
1243
|
+
result = {'version': 1, 'brand': brand.nombre, 'status': 'attention_required',
|
|
1244
|
+
'heartbeat': observation, 'persistent_supervision': 'unverified',
|
|
1245
|
+
'unattended_delivery_guaranteed': False, 'server_media_storage': False,
|
|
1246
|
+
'error': diagnostic,
|
|
1247
|
+
'instruction': 'Keep the original files, credentials and a supervised executor available on '
|
|
1248
|
+
'the customer-owned machine. Use schedule-install on macOS or Linux. '
|
|
1249
|
+
'Do not promise delivery from an ephemeral Muse/Grok session.'}
|
|
1250
|
+
if sys.platform.startswith('linux'):
|
|
1251
|
+
from socialctl.scheduler_systemd import inspect
|
|
1252
|
+
supervisor = inspect(brand.nombre,root)
|
|
1253
|
+
result['supervisor'] = supervisor
|
|
1254
|
+
supervised = (supervisor['configuration_matches'] and supervisor['enabled'] and supervisor['active']
|
|
1255
|
+
and supervisor['survives_logout'] is True and supervisor['service_failed'] is False)
|
|
1256
|
+
if supervised:
|
|
1257
|
+
result['persistent_supervision'] = 'verified_now'
|
|
1258
|
+
if not observation['stale'] and observation.get('state') != 'error':
|
|
1259
|
+
result['status'] = 'ready'
|
|
1260
|
+
result['error'] = None
|
|
1261
|
+
elif observation.get('state') != 'error' and not observation['stale']:
|
|
1262
|
+
if supervisor['service_failed']:
|
|
1263
|
+
code,message = 'supervisor_service_failed','The Linux service failed; inspect journalctl --user before relying on the queue.'
|
|
1264
|
+
elif supervisor['active'] and supervisor['survives_logout'] is not True:
|
|
1265
|
+
code,message = 'logout_persistence_unverified','The timer is active, but persistence after logout requires operator-verified linger.'
|
|
1266
|
+
else:
|
|
1267
|
+
code,message = 'supervision_unverified','An enabled active Linux user timer with matching configuration was not verified.'
|
|
1268
|
+
result['error'] = {'code':code,'message':message,'action':'user_action'}
|
|
1269
|
+
elif sys.platform == 'darwin':
|
|
1270
|
+
from socialctl.scheduler_launchd import inspect
|
|
1271
|
+
supervisor = inspect(brand.nombre, root)
|
|
1272
|
+
result['supervisor'] = supervisor
|
|
1273
|
+
supervised = all(supervisor[key] is True for key in
|
|
1274
|
+
('configuration_matches', 'registered', 'enabled', 'active')) and supervisor['service_failed'] is False
|
|
1275
|
+
if supervised:
|
|
1276
|
+
result['persistent_supervision'] = 'verified_now'
|
|
1277
|
+
if not observation['stale'] and observation.get('state') == 'finished':
|
|
1278
|
+
result['status'] = 'ready'
|
|
1279
|
+
result['error'] = None
|
|
1280
|
+
elif observation.get('state') != 'error' and not observation['stale']:
|
|
1281
|
+
failed = supervisor['service_failed'] is True
|
|
1282
|
+
result['error'] = {
|
|
1283
|
+
'code': 'supervisor_service_failed' if failed else 'supervision_unverified',
|
|
1284
|
+
'message': ('The macOS scheduler last execution failed; inspect its logs and launchctl before relying on the queue.'
|
|
1285
|
+
if failed else 'A matching registered and enabled macOS scheduler with successful execution was not verified.'),
|
|
1286
|
+
'action': 'user_action'}
|
|
1287
|
+
|
|
1288
|
+
if json_output:
|
|
1289
|
+
typer.echo(json.dumps(result, indent=2))
|
|
1290
|
+
else:
|
|
1291
|
+
typer.echo(result['error']['message'] if result['error'] else 'Executor and persistent supervision verified now; future delivery still depends on this machine.')
|
|
1292
|
+
typer.echo(result.get('instruction', ''))
|
|
1293
|
+
raise typer.Exit(0 if result['status'] == 'ready' else 1)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
@app.command("schedule-uninstall")
|
|
1297
|
+
def schedule_uninstall(
|
|
1298
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand whose agent to remove."),
|
|
1299
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace containing the brand's scheduler."),
|
|
1300
|
+
) -> None:
|
|
1301
|
+
"""Remove this workspace's local queue supervisor without deleting queue or media."""
|
|
1302
|
+
if sys.platform.startswith('linux'):
|
|
1303
|
+
from socialctl.scheduler_systemd import uninstall, SupervisorError
|
|
1304
|
+
try:
|
|
1305
|
+
removed = uninstall(brand_nombre,root)
|
|
1306
|
+
except (SupervisorError,OSError) as exc:
|
|
1307
|
+
_fallar(str(exc) if isinstance(exc,SupervisorError) else 'Could not remove Linux scheduler units')
|
|
1308
|
+
typer.echo('Linux timer removed. An already running approved operation may finish; queue and media are preserved.' if removed else 'No Linux scheduler units found for this workspace and brand.')
|
|
1309
|
+
return
|
|
1310
|
+
from socialctl.scheduler_launchd import uninstall, SupervisorError
|
|
1311
|
+
try:
|
|
1312
|
+
removed = uninstall(brand_nombre, root)
|
|
1313
|
+
except (SupervisorError, OSError) as exc:
|
|
1314
|
+
_fallar(str(exc) if isinstance(exc, SupervisorError) else 'Could not remove macOS scheduler configuration.')
|
|
1315
|
+
typer.echo('macOS scheduler removed; queue, media and credentials are preserved. Review any interrupted operation before retrying.'
|
|
1316
|
+
if removed else 'No macOS scheduler found for this workspace and brand. Legacy and unrelated jobs were preserved.')
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
@app.command()
|
|
1320
|
+
def retry(
|
|
1321
|
+
slug: str,
|
|
1322
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand to retry."),
|
|
1323
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
1324
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
|
|
1325
|
+
json_output: bool = typer.Option(False, "--json", help="One JSON result; complete preview and approval UI on stderr."),
|
|
1326
|
+
) -> None:
|
|
1327
|
+
"""Retry failed platforms only after validation and preview, excluding outcomes that may duplicate publications."""
|
|
1328
|
+
with PublishOutput(json_output, command="retry", brand=brand_nombre, slug=slug) as report:
|
|
1329
|
+
_retry_impl(slug, brand_nombre, root, yes, report=report)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _retry_impl(slug, brand_nombre, root, yes, *, report=None):
|
|
1333
|
+
brand = _cargar_marca(root, brand_nombre)
|
|
1334
|
+
post = _cargar_post_con_avisos(brand, slug)
|
|
1335
|
+
|
|
1336
|
+
from socialctl.publication_steps import retry_media_blockers, CommentError
|
|
1337
|
+
try:
|
|
1338
|
+
protected = retry_media_blockers(brand, post.slug, post.platforms)
|
|
1339
|
+
except CommentError as exc:
|
|
1340
|
+
_fallar(str(exc))
|
|
1341
|
+
if report is not None:
|
|
1342
|
+
report.retry["blocked"] = sorted(p.value for p in protected)
|
|
1343
|
+
for platform in protected:
|
|
1344
|
+
report.add_error("operation_uncertain", platform=platform.value)
|
|
1345
|
+
for platform, reasons in protected.items():
|
|
1346
|
+
for reason in reasons:
|
|
1347
|
+
typer.echo(f"{platform.value}: {reason}. See comments publication-status / retry-first.")
|
|
1348
|
+
|
|
1349
|
+
fichero = brand.dir_posts / post.slug / "resultado.json"
|
|
1350
|
+
if not fichero.exists():
|
|
1351
|
+
if protected:
|
|
1352
|
+
_fallar("resultado.json is missing; the durable journal requires reconciliation, not another upload.")
|
|
1353
|
+
_fallar(f'no previous attempt in {fichero}; nothing to retry.')
|
|
1354
|
+
|
|
1355
|
+
try:
|
|
1356
|
+
contenido = json.loads(fichero.read_text(encoding="utf-8"))
|
|
1357
|
+
if not isinstance(contenido, dict):
|
|
1358
|
+
raise ValueError("JSON root is not an object")
|
|
1359
|
+
entradas = contenido["resultados"]
|
|
1360
|
+
if not isinstance(entradas, list):
|
|
1361
|
+
raise ValueError("'resultados' is not a list")
|
|
1362
|
+
previos: dict[str, dict] = {}
|
|
1363
|
+
for entrada in entradas:
|
|
1364
|
+
if not isinstance(entrada, dict) or not isinstance(entrada.get("platform"), str):
|
|
1365
|
+
raise ValueError("a 'resultados' entry does not have the expected structure")
|
|
1366
|
+
previos[entrada["platform"]] = entrada
|
|
1367
|
+
except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
|
|
1368
|
+
_fallar(f"could not parse {fichero}: {exc}")
|
|
1369
|
+
|
|
1370
|
+
fallidas = {
|
|
1371
|
+
plataforma for plataforma, entrada in previos.items()
|
|
1372
|
+
if entrada.get("status") == PostStatus.ERROR.value
|
|
1373
|
+
}
|
|
1374
|
+
if not fallidas:
|
|
1375
|
+
typer.echo("There are no failed platforms to retry.")
|
|
1376
|
+
raise typer.Exit(1 if protected else 0)
|
|
1377
|
+
|
|
1378
|
+
actuales = {p.value for p in post.platforms}
|
|
1379
|
+
faltantes = sorted(fallidas - actuales)
|
|
1380
|
+
vigentes = sorted(fallidas & actuales)
|
|
1381
|
+
|
|
1382
|
+
if faltantes:
|
|
1383
|
+
typer.echo(
|
|
1384
|
+
f"Warning: post.yml no longer includes {', '.join(faltantes)} "
|
|
1385
|
+
"(they failed in the previous attempt); they will not be retried."
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
if not vigentes:
|
|
1389
|
+
typer.echo("No failed platform remaining in this post can be retried.")
|
|
1390
|
+
raise typer.Exit(0)
|
|
1391
|
+
|
|
1392
|
+
# Estrictamente `is True`, no `bool(...)`: un valor no booleano colado en
|
|
1393
|
+
# el JSON (p. ej. la cadena "false", que `bool("false")` evaluaría como
|
|
1394
|
+
# verdadera) nunca debe convertir una red en "arriesgada" por accidente.
|
|
1395
|
+
# Una entrada sin este campo -de un resultado.json anterior a que
|
|
1396
|
+
# existiera- también cae aquí, y por tanto no se considera arriesgada
|
|
1397
|
+
# (ver docstring de esta función para la justificación).
|
|
1398
|
+
arriesgadas = sorted(
|
|
1399
|
+
p for p in vigentes if previos[p].get("riesgo_duplicado") is True
|
|
1400
|
+
)
|
|
1401
|
+
seguras = [p for p in vigentes if p not in arriesgadas and Platform(p) not in protected]
|
|
1402
|
+
|
|
1403
|
+
if report is not None:
|
|
1404
|
+
report.retry["blocked"] = sorted(set(report.retry["blocked"]) | set(arriesgadas))
|
|
1405
|
+
report.retry["selected"] = seguras
|
|
1406
|
+
for platform in arriesgadas:
|
|
1407
|
+
if Platform(platform) not in protected:
|
|
1408
|
+
report.add_error("operation_uncertain", platform=platform)
|
|
1409
|
+
|
|
1410
|
+
if arriesgadas:
|
|
1411
|
+
typer.echo(
|
|
1412
|
+
"\nWARNING: these platforms will not be retried automatically because of "
|
|
1413
|
+
"duplicate publication risk (the previous attempt warned "
|
|
1414
|
+
"that the content may have been published):"
|
|
1415
|
+
)
|
|
1416
|
+
for p in arriesgadas:
|
|
1417
|
+
typer.echo(f" - {p}: {safe_error(previos[p].get('error') or '')}")
|
|
1418
|
+
typer.echo(
|
|
1419
|
+
"Check the account manually before publishing on these platforms again "
|
|
1420
|
+
"(for example: socialcli publish ... --only <platform> --yes)."
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
if not seguras:
|
|
1424
|
+
raise typer.Exit(1)
|
|
1425
|
+
|
|
1426
|
+
typer.echo(f"\nRetrying: {', '.join(seguras)}")
|
|
1427
|
+
|
|
1428
|
+
codigo_publish = 0
|
|
1429
|
+
try:
|
|
1430
|
+
_publicar_impl(
|
|
1431
|
+
post, brand, dry_run=False, yes=yes,
|
|
1432
|
+
only=[Platform(p) for p in seguras],
|
|
1433
|
+
motivo_exclusion="not retried in this attempt",
|
|
1434
|
+
retry_guard=True,
|
|
1435
|
+
report=report,
|
|
1436
|
+
)
|
|
1437
|
+
except typer.Exit as exc:
|
|
1438
|
+
codigo_publish = exc.exit_code or 0
|
|
1439
|
+
|
|
1440
|
+
if arriesgadas or protected or codigo_publish:
|
|
1441
|
+
raise typer.Exit(1)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
#: Días seguidos en fallo tras los que `stats` avisa por pantalla. Tres, como
|
|
1445
|
+
#: pide el spec (§5): uno puede ser un corte de red, dos una coincidencia;
|
|
1446
|
+
#: tres seguidos es un token caducado que lleva días comiéndose el histórico.
|
|
1447
|
+
DIAS_DE_FALLO_PARA_AVISAR = 3
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _redes_en_fallo(snapshot: Snapshot) -> set[Platform]:
|
|
1451
|
+
"""Return platforms with error or missing-credential read states."""
|
|
1452
|
+
return {
|
|
1453
|
+
platform
|
|
1454
|
+
for platform, lectura in snapshot.redes.items()
|
|
1455
|
+
if lectura.estado
|
|
1456
|
+
in (EstadoLectura.ERROR, EstadoLectura.SIN_CREDENCIALES)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _avisar_de_fallos_persistentes(marca: Brand, snapshot: Snapshot) -> None:
|
|
1461
|
+
"""Warn when a platform fails in three consecutive snapshots."""
|
|
1462
|
+
anterior = snapshot_anterior(marca, antes_de=snapshot.fecha)
|
|
1463
|
+
if anterior is None:
|
|
1464
|
+
return
|
|
1465
|
+
trasanterior = snapshot_anterior(marca, antes_de=anterior.fecha)
|
|
1466
|
+
if trasanterior is None:
|
|
1467
|
+
return
|
|
1468
|
+
|
|
1469
|
+
persistentes = (
|
|
1470
|
+
_redes_en_fallo(snapshot)
|
|
1471
|
+
& _redes_en_fallo(anterior)
|
|
1472
|
+
& _redes_en_fallo(trasanterior)
|
|
1473
|
+
)
|
|
1474
|
+
for platform in sorted(persistentes, key=lambda p: p.value):
|
|
1475
|
+
typer.echo(
|
|
1476
|
+
f"WARNING: {platform.value} has gone {DIAS_DE_FALLO_PARA_AVISAR} days "
|
|
1477
|
+
f"without a successful read (also on {anterior.fecha.isoformat()} and "
|
|
1478
|
+
f"on {trasanterior.fecha.isoformat()}). This usually means an "
|
|
1479
|
+
f"expired token: run socialcli auth {platform.value} "
|
|
1480
|
+
f"--brand {marca.nombre}"
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _limpiar_snapshots_y_avisar(marca: Brand) -> None:
|
|
1485
|
+
"""Apply one-year snapshot retention and report deletions or failures."""
|
|
1486
|
+
borrados, fallidos = limpiar_snapshots_antiguos(marca)
|
|
1487
|
+
if borrados:
|
|
1488
|
+
nombres = ", ".join(ruta.name for ruta in borrados)
|
|
1489
|
+
typer.echo(
|
|
1490
|
+
f"Cleanup: deleted {len(borrados)} snapshot(s) older than "
|
|
1491
|
+
f"{RETENCION_SNAPSHOTS_DIAS} days ({nombres})."
|
|
1492
|
+
)
|
|
1493
|
+
for ruta, exc in fallidos:
|
|
1494
|
+
typer.echo(
|
|
1495
|
+
f"WARNING: could not delete the old snapshot {ruta} ({exc}). "
|
|
1496
|
+
"It remains on disk; delete it manually to free the space."
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _fusionar_snapshot_del_dia(marca: Brand, nuevo: Snapshot) -> Snapshot:
|
|
1501
|
+
"""Merge selected-platform reads with today's snapshot, retaining other platforms and previous good data when a read fails."""
|
|
1502
|
+
anterior = cargar_snapshot(marca, nuevo.fecha)
|
|
1503
|
+
if anterior is None:
|
|
1504
|
+
return nuevo
|
|
1505
|
+
|
|
1506
|
+
fusionadas = dict(anterior.redes)
|
|
1507
|
+
for platform, lectura in nuevo.redes.items():
|
|
1508
|
+
previa = anterior.redes.get(platform)
|
|
1509
|
+
if (
|
|
1510
|
+
lectura.estado is not EstadoLectura.OK
|
|
1511
|
+
and previa is not None
|
|
1512
|
+
and previa.estado is EstadoLectura.OK
|
|
1513
|
+
):
|
|
1514
|
+
fusionadas[platform] = previa.model_copy(
|
|
1515
|
+
update={"estado": lectura.estado, "error": lectura.error}
|
|
1516
|
+
)
|
|
1517
|
+
else:
|
|
1518
|
+
fusionadas[platform] = lectura
|
|
1519
|
+
|
|
1520
|
+
return Snapshot(fecha=nuevo.fecha, marca=nuevo.marca, redes=fusionadas)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
class MetricsPersistenceError(Exception):
|
|
1524
|
+
def __init__(self, saved, not_saved, message, snapshot_path):
|
|
1525
|
+
super().__init__(message)
|
|
1526
|
+
self.saved, self.not_saved, self.snapshot_path = list(saved), list(not_saved), snapshot_path
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _guardar_lo_leido(marca: Brand, snapshot: Snapshot, *, structured: bool = False) -> Path:
|
|
1530
|
+
"""Write the day's snapshot, editorial inventory and report atomically, reporting any incomplete set of writes."""
|
|
1531
|
+
pasos = (
|
|
1532
|
+
("today's snapshot", lambda: guardar_snapshot(marca, snapshot)),
|
|
1533
|
+
("piezas.yml", lambda: actualizar_piezas(marca, snapshot)),
|
|
1534
|
+
("resumen.md", lambda: escribir_resumen(marca, snapshot)),
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
escritos: list[str] = []
|
|
1538
|
+
pendientes = [nombre for nombre, _ in pasos]
|
|
1539
|
+
ruta_del_snapshot: Path | None = None
|
|
1540
|
+
|
|
1541
|
+
for nombre, escribir in pasos:
|
|
1542
|
+
try:
|
|
1543
|
+
ruta = escribir()
|
|
1544
|
+
except PiezasIlegibles as exc:
|
|
1545
|
+
if structured:
|
|
1546
|
+
raise MetricsPersistenceError(escritos, pendientes, str(exc), ruta_del_snapshot) from None
|
|
1547
|
+
_fallar_guardando(
|
|
1548
|
+
escritos, pendientes,
|
|
1549
|
+
f"could not update piezas.yml: {exc}",
|
|
1550
|
+
"That file was preserved: its editorial content "
|
|
1551
|
+
"cannot be retrieved again from an API.",
|
|
1552
|
+
)
|
|
1553
|
+
except OSError as exc:
|
|
1554
|
+
if structured:
|
|
1555
|
+
raise MetricsPersistenceError(escritos, pendientes, str(exc), ruta_del_snapshot) from None
|
|
1556
|
+
_fallar_guardando(
|
|
1557
|
+
escritos, pendientes,
|
|
1558
|
+
f"could not write {nombre}: {exc}",
|
|
1559
|
+
"Files are written "
|
|
1560
|
+
"atomically, so unsaved files retain their "
|
|
1561
|
+
"complete previous content.",
|
|
1562
|
+
)
|
|
1563
|
+
escritos.append(f"{nombre} ({ruta})")
|
|
1564
|
+
pendientes.remove(nombre)
|
|
1565
|
+
if ruta_del_snapshot is None:
|
|
1566
|
+
ruta_del_snapshot = ruta
|
|
1567
|
+
|
|
1568
|
+
assert ruta_del_snapshot is not None # el primer paso siempre lo fija
|
|
1569
|
+
return ruta_del_snapshot
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _fallar_guardando(
|
|
1573
|
+
escritos: list[str], pendientes: list[str], motivo: str, coletilla: str
|
|
1574
|
+
) -> None:
|
|
1575
|
+
"""Exit stats with a clear account of saved and unsaved files."""
|
|
1576
|
+
lineas = [f"ERROR: {motivo}"]
|
|
1577
|
+
lineas.append(
|
|
1578
|
+
"Saved: " + "; ".join(escritos) + "."
|
|
1579
|
+
if escritos
|
|
1580
|
+
else "None of this run's files were saved."
|
|
1581
|
+
)
|
|
1582
|
+
if pendientes:
|
|
1583
|
+
lineas.append("Not saved: " + ", ".join(pendientes) + ".")
|
|
1584
|
+
if coletilla:
|
|
1585
|
+
lineas.append(coletilla)
|
|
1586
|
+
_fallar("\n".join(lineas))
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _metric_read_label(state: str) -> str:
|
|
1590
|
+
"""Render English metric-read labels while preserving stored state codes."""
|
|
1591
|
+
return {"sin_credenciales": "missing_credentials", "sin_permiso": "missing_permission",
|
|
1592
|
+
"no_disponible": "unavailable"}.get(state, state)
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
@app.command()
|
|
1596
|
+
def stats(
|
|
1597
|
+
brand_nombre: str = typer.Option(..., "--brand", help="Brand whose metrics to read."),
|
|
1598
|
+
root: Path = typer.Option(RAIZ_POR_DEFECTO, help="Workspace directory containing brands."),
|
|
1599
|
+
only: list[str] = typer.Option(None, "--only", help="Read only these platforms."),
|
|
1600
|
+
desde: str = typer.Option(
|
|
1601
|
+
None, "--since", "--desde", help="Only content published since this date (YYYY-MM-DD)."
|
|
1602
|
+
),
|
|
1603
|
+
json_output: bool = typer.Option(False, "--json", help="Print a versioned report; progress goes to stderr."),
|
|
1604
|
+
timeout: float = typer.Option(300, "--timeout", help="Total read time limit in seconds; use --only to give one platform the full budget."),
|
|
1605
|
+
) -> None:
|
|
1606
|
+
"""Read platform metrics and save a brand snapshot. Selected-platform reads merge with today's data; YouTube API data expires after six days; other snapshots expire after one year."""
|
|
1607
|
+
def invalid(message):
|
|
1608
|
+
if json_output:
|
|
1609
|
+
from socialctl.read_reports import ReadReport, emit_report, safe_error
|
|
1610
|
+
emit_report(ReadReport(status='error', errors=[{'code':'invalid_input','message':safe_error(message)}]))
|
|
1611
|
+
raise typer.Exit(2)
|
|
1612
|
+
_fallar(message)
|
|
1613
|
+
try:
|
|
1614
|
+
marca = cargar_brand(root, brand_nombre)
|
|
1615
|
+
except _EXCEPCIONES_MARCA as exc:
|
|
1616
|
+
invalid(str(exc))
|
|
1617
|
+
|
|
1618
|
+
fecha_desde = None
|
|
1619
|
+
if desde is not None:
|
|
1620
|
+
try:
|
|
1621
|
+
fecha_desde = date.fromisoformat(desde)
|
|
1622
|
+
except ValueError:
|
|
1623
|
+
invalid(f"'--since {desde}' is not a valid YYYY-MM-DD date.")
|
|
1624
|
+
|
|
1625
|
+
# Mismo patrón que `publish` (`socialctl/cli.py:638-643`): la opción se
|
|
1626
|
+
# declara como texto y se convierte aquí, para que una red desconocida dé
|
|
1627
|
+
# un mensaje en español en vez del error de click, que sale en inglés.
|
|
1628
|
+
destinos: list[Platform]
|
|
1629
|
+
if only:
|
|
1630
|
+
try:
|
|
1631
|
+
destinos = [Platform(r) for r in only]
|
|
1632
|
+
except ValueError:
|
|
1633
|
+
validas = ", ".join(p.value for p in Platform)
|
|
1634
|
+
invalid(f"--only contains an unknown platform. Valid platforms: {validas}")
|
|
1635
|
+
else:
|
|
1636
|
+
destinos = list(LECTORES)
|
|
1637
|
+
|
|
1638
|
+
from socialctl.read_budget import ReadBudget, ReadDeadlineExceeded, read_client
|
|
1639
|
+
from socialctl.metricas.modelos import LecturaRed
|
|
1640
|
+
try:
|
|
1641
|
+
budget = ReadBudget(timeout)
|
|
1642
|
+
except ValueError as exc:
|
|
1643
|
+
invalid(str(exc))
|
|
1644
|
+
redes = {}
|
|
1645
|
+
from socialctl.read_budget import ReadProgress
|
|
1646
|
+
progress = ReadProgress('stats')
|
|
1647
|
+
interrupted = False
|
|
1648
|
+
try:
|
|
1649
|
+
transport = read_client(budget, progress=progress)
|
|
1650
|
+
except Exception as exc:
|
|
1651
|
+
from socialctl.read_reports import fail_read
|
|
1652
|
+
fail_read(exc,json_output=json_output)
|
|
1653
|
+
with progress, transport as client:
|
|
1654
|
+
for index, platform in enumerate(destinos):
|
|
1655
|
+
if interrupted:
|
|
1656
|
+
redes[platform] = LecturaRed(estado=EstadoLectura.ERROR, error='Read interrupted; platform not started', failure_code='not_attempted')
|
|
1657
|
+
continue
|
|
1658
|
+
try:
|
|
1659
|
+
with budget.share(len(destinos) - index):
|
|
1660
|
+
budget.check()
|
|
1661
|
+
if sys.stderr.isatty():
|
|
1662
|
+
typer.echo(f"Reading {platform.value}...", err=True)
|
|
1663
|
+
redes[platform] = leer_red(platform, marca, client, fecha_desde)
|
|
1664
|
+
redes[platform].observed_at = datetime.now(timezone.utc)
|
|
1665
|
+
except KeyboardInterrupt:
|
|
1666
|
+
interrupted = True
|
|
1667
|
+
redes[platform] = LecturaRed(estado=EstadoLectura.ERROR, error='Read interrupted; results may be incomplete', failure_code='read_interrupted')
|
|
1668
|
+
except ReadDeadlineExceeded as exc:
|
|
1669
|
+
redes[platform] = LecturaRed(estado=EstadoLectura.ERROR, error=str(exc), failure_code='read_deadline')
|
|
1670
|
+
|
|
1671
|
+
from socialctl.read_reports import safe_error
|
|
1672
|
+
for lectura in redes.values():
|
|
1673
|
+
lectura.warnings = [safe_error(warning) for warning in lectura.warnings]
|
|
1674
|
+
if lectura.error:
|
|
1675
|
+
lectura.error = safe_error(lectura.error)
|
|
1676
|
+
for piece in lectura.piezas:
|
|
1677
|
+
if 'enriquecimiento_fallido' in piece.especificas:
|
|
1678
|
+
piece.especificas['enriquecimiento_fallido'] = safe_error(str(piece.especificas['enriquecimiento_fallido']))
|
|
1679
|
+
snapshot = Snapshot(fecha=date.today(), marca=marca.nombre, redes=redes)
|
|
1680
|
+
|
|
1681
|
+
# Con `--only`, fusiona con el snapshot que ya hubiera hoy en vez de
|
|
1682
|
+
# reemplazarlo (ver `_fusionar_snapshot_del_dia`); sin `--only` no se
|
|
1683
|
+
# toca nada aquí y la lectura completa sigue sobrescribiendo como
|
|
1684
|
+
# siempre. `redes` (usado más abajo para el resumen por pantalla y el
|
|
1685
|
+
# código de salida) sigue siendo la lectura de HOY, sin fusionar: lo que
|
|
1686
|
+
# se cuenta ahí es lo que ha pasado en ESTA ejecución.
|
|
1687
|
+
reused = set()
|
|
1688
|
+
if only:
|
|
1689
|
+
previous = cargar_snapshot(marca, snapshot.fecha)
|
|
1690
|
+
if previous:
|
|
1691
|
+
reused = {p for p, r in previous.redes.items() if p not in redes or
|
|
1692
|
+
(r.estado is EstadoLectura.OK and redes[p].estado is not EstadoLectura.OK)}
|
|
1693
|
+
snapshot = _fusionar_snapshot_del_dia(marca, snapshot)
|
|
1694
|
+
|
|
1695
|
+
# ANTES de guardar y de actualizar `piezas.yml`: el slug enlaza cada
|
|
1696
|
+
# pieza con el post que la publicó, y tiene que llegar a los dos ficheros.
|
|
1697
|
+
asignar_slugs(marca, snapshot)
|
|
1698
|
+
|
|
1699
|
+
persistence_error = None
|
|
1700
|
+
try:
|
|
1701
|
+
ruta = _guardar_lo_leido(marca, snapshot, structured=json_output)
|
|
1702
|
+
except MetricsPersistenceError as exc:
|
|
1703
|
+
persistence_error = exc
|
|
1704
|
+
ruta = exc.snapshot_path
|
|
1705
|
+
|
|
1706
|
+
if json_output:
|
|
1707
|
+
from contextlib import redirect_stdout
|
|
1708
|
+
from socialctl.read_reports import ReadReport, emit_report, safe_error
|
|
1709
|
+
with redirect_stdout(sys.stderr):
|
|
1710
|
+
_limpiar_snapshots_y_avisar(marca)
|
|
1711
|
+
_avisar_de_fallos_persistentes(marca, snapshot)
|
|
1712
|
+
coverage, errors = [], []
|
|
1713
|
+
for p, r in redes.items():
|
|
1714
|
+
warnings = r.warnings + [piece.especificas['enriquecimiento_fallido'] for piece in r.piezas
|
|
1715
|
+
if 'enriquecimiento_fallido' in piece.especificas]
|
|
1716
|
+
complete = r.estado is EstadoLectura.OK and not warnings
|
|
1717
|
+
observed = snapshot.redes[p].observed_at
|
|
1718
|
+
coverage.append({'platform':p.value, 'complete':complete, 'state':r.estado.value,
|
|
1719
|
+
'fresh':p not in reused and r.estado is EstadoLectura.OK,
|
|
1720
|
+
'observed_at':observed.isoformat() if observed else None,
|
|
1721
|
+
'source':'previous_snapshot' if p in reused else 'current_read'})
|
|
1722
|
+
if not complete:
|
|
1723
|
+
errors.append({'platform':p.value, 'code':r.failure_code or {EstadoLectura.SIN_CREDENCIALES:'authorization_unavailable', EstadoLectura.SIN_PERMISO:'permissions_required'}.get(r.estado,'read_partial'), 'message':safe_error(r.error or '; '.join(warnings) or r.estado.value)})
|
|
1724
|
+
if persistence_error:
|
|
1725
|
+
errors.append({'operation':'persistence', 'code':'persistence_failed', 'message':safe_error(str(persistence_error))})
|
|
1726
|
+
successes = sum(r.estado is EstadoLectura.OK for r in redes.values())
|
|
1727
|
+
status = 'ok' if not errors else ('partial' if successes else 'error')
|
|
1728
|
+
data = {'brand':marca.nombre, 'snapshot_path':str(ruta) if ruta else None,
|
|
1729
|
+
'snapshot':snapshot.model_dump(mode='json'),
|
|
1730
|
+
'merged_existing_platforms':sorted(p.value for p in reused)}
|
|
1731
|
+
if persistence_error:
|
|
1732
|
+
data['persistence'] = {'saved':persistence_error.saved, 'not_saved':persistence_error.not_saved}
|
|
1733
|
+
report = ReadReport(status=status, coverage=coverage, errors=errors, data=data)
|
|
1734
|
+
raise typer.Exit(emit_report(report))
|
|
1735
|
+
|
|
1736
|
+
typer.echo("")
|
|
1737
|
+
for platform, lectura in redes.items():
|
|
1738
|
+
if lectura.estado is EstadoLectura.OK:
|
|
1739
|
+
typer.echo(f"{platform.value}: ok, {len(lectura.piezas)} content items")
|
|
1740
|
+
for warning in lectura.warnings:
|
|
1741
|
+
typer.echo(f"{platform.value}: partial — {warning}")
|
|
1742
|
+
else:
|
|
1743
|
+
typer.echo(f"{platform.value}: {_metric_read_label(lectura.estado.value)} — {lectura.error}")
|
|
1744
|
+
|
|
1745
|
+
typer.echo(f"\nSnapshot: {ruta}")
|
|
1746
|
+
|
|
1747
|
+
# Retención (spec §3): corre con cada `stats`, después de guardar el
|
|
1748
|
+
# snapshot de hoy -nunca antes-, para que un fallo al borrar un fichero
|
|
1749
|
+
# viejo no se lleve por delante una lectura que ya ha costado cuota de
|
|
1750
|
+
# API en las cuatro redes.
|
|
1751
|
+
_limpiar_snapshots_y_avisar(marca)
|
|
1752
|
+
|
|
1753
|
+
# El tercer punto de la cadencia del spec (§5). Va después del resumen por
|
|
1754
|
+
# red para que lo último que se lea sea lo que hay que hacer.
|
|
1755
|
+
_avisar_de_fallos_persistentes(marca, snapshot)
|
|
1756
|
+
|
|
1757
|
+
if all(l.estado is not EstadoLectura.OK for l in redes.values()):
|
|
1758
|
+
raise typer.Exit(1)
|
|
1759
|
+
if persistence_error or any(l.estado is not EstadoLectura.OK or l.warnings or
|
|
1760
|
+
any('enriquecimiento_fallido' in piece.especificas for piece in l.piezas)
|
|
1761
|
+
for l in redes.values()):
|
|
1762
|
+
raise typer.Exit(3)
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
if __name__ == "__main__":
|
|
1766
|
+
app()
|