socialcli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of socialcli might be problematic. Click here for more details.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- socialctl/youtube_data_service.py +225 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Local diagnostics across ordinary publication routes, preserving partial results."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from socialctl.adapters import ADAPTADORES
|
|
5
|
+
from socialctl.media import leer_media,MediaNoEncontrada,MediaInvalida
|
|
6
|
+
from socialctl.media_requirements import SOURCES,technical_issues
|
|
7
|
+
from socialctl.models import MediaAsset,MediaKind,Platform
|
|
8
|
+
from socialctl.postfile import _ruta,_cargar_datos,_construir_platform_post,_validar_nombre_de_media,PostInvalido
|
|
9
|
+
from socialctl.publication_policy import validate_derivation
|
|
10
|
+
from socialctl.read_reports import safe_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def issue(code,message,*,field='media',action='supply_compatible_media'):
|
|
14
|
+
return {'code':code,'field':field,'action':action,'retryable':False,'message':safe_error(message)}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_post(brand,slug,only=()):
|
|
18
|
+
path=_ruta(brand,slug)
|
|
19
|
+
raw=_cargar_datos(path)
|
|
20
|
+
blocks=raw.get('platforms')
|
|
21
|
+
if not isinstance(blocks,dict) or not blocks:raise PostInvalido('post requires platform mappings')
|
|
22
|
+
try:available={Platform(p) for p in blocks}
|
|
23
|
+
except ValueError:raise PostInvalido('unknown platform in post') from None
|
|
24
|
+
selected=set(only) if only else available
|
|
25
|
+
if not selected<=available:raise PostInvalido('selected platform absent from post')
|
|
26
|
+
cache={};platforms={}
|
|
27
|
+
for platform in sorted(selected,key=lambda p:p.value):
|
|
28
|
+
diagnostics=[];assets=[]
|
|
29
|
+
def read(file,*,ruta_relativa):
|
|
30
|
+
key=str(file.resolve())
|
|
31
|
+
if key not in cache:
|
|
32
|
+
try:
|
|
33
|
+
asset=leer_media(file,ruta_relativa=ruta_relativa)
|
|
34
|
+
cache[key]=(asset,None)
|
|
35
|
+
except (MediaNoEncontrada,FileNotFoundError):
|
|
36
|
+
cache[key]=(None,issue('media_missing','Supplied file is missing.'))
|
|
37
|
+
except MediaInvalida as exc:
|
|
38
|
+
action='install_probe' if exc.code=='probe_unavailable' else 'inspect_file' if exc.code=='probe_timeout' else 'supply_compatible_media'
|
|
39
|
+
cache[key]=(None,issue(exc.code,'Local media inspection did not produce usable metadata.',action=action))
|
|
40
|
+
except (OSError,ValueError,TypeError):
|
|
41
|
+
cache[key]=(None,issue('media_unreadable','Supplied file could not be inspected.'))
|
|
42
|
+
asset,error=cache[key]
|
|
43
|
+
record={'file':safe_error(ruta_relativa),'status':'blocked' if error else 'inspected'}
|
|
44
|
+
if error:
|
|
45
|
+
diagnostics.append({**error,'file':safe_error(ruta_relativa)})
|
|
46
|
+
record['issue']=error
|
|
47
|
+
asset=MediaAsset(path=file,kind=MediaKind.VIDEO,ruta_relativa=ruta_relativa)
|
|
48
|
+
else:
|
|
49
|
+
record['metadata']={'kind':asset.kind.value,'width':asset.width,'height':asset.height,
|
|
50
|
+
'duration_s':asset.duration_s,'size_bytes':asset.size_bytes,'technical':asset.probe}
|
|
51
|
+
assets.append(record)
|
|
52
|
+
return asset
|
|
53
|
+
block=blocks[platform.value]
|
|
54
|
+
prepared={}
|
|
55
|
+
# Inspect each path independently, even when other post fields are invalid.
|
|
56
|
+
if isinstance(block,dict) and isinstance(block.get('media',[]),list):
|
|
57
|
+
safe_names=[]
|
|
58
|
+
for index,name in enumerate(block.get('media',[])):
|
|
59
|
+
try:
|
|
60
|
+
file=_validar_nombre_de_media(brand.raiz/'media',name)
|
|
61
|
+
relative=file.relative_to(brand.raiz/'media').as_posix()
|
|
62
|
+
except (ValueError,TypeError,OSError):
|
|
63
|
+
error=issue('media_path_invalid','Supply a relative file path inside the brand media directory.')
|
|
64
|
+
label=safe_error(name) if isinstance(name,str) else f'entry {index+1}'
|
|
65
|
+
assets.append({'file':label,'status':'blocked','issue':error})
|
|
66
|
+
diagnostics.append({**error,'file':label})
|
|
67
|
+
continue
|
|
68
|
+
prepared[str(file)]=read(file,ruta_relativa=relative)
|
|
69
|
+
safe_names.append(name)
|
|
70
|
+
block={**block,'media':safe_names}
|
|
71
|
+
try:
|
|
72
|
+
post=_construir_platform_post(platform,block,brand.raiz/'media',path,
|
|
73
|
+
media_reader=lambda file,**kwargs:prepared[str(file)])
|
|
74
|
+
errors=ADAPTADORES[platform]().validate(post,brand)+validate_derivation(post,require_origin=True)
|
|
75
|
+
detailed=[i for asset in post.media for i in technical_issues(platform,asset)]
|
|
76
|
+
detailed_messages={i['message'] for i in detailed}
|
|
77
|
+
diagnostics.extend({**i,'field':'media'} for i in detailed)
|
|
78
|
+
for error in errors:
|
|
79
|
+
if error.motivo in detailed_messages:continue
|
|
80
|
+
action=('configure_account' if error.campo in {'cuenta','auditoria','credenciales'}
|
|
81
|
+
else 'supply_compatible_media' if error.campo in {'media','duracion','formato'} else 'review_post')
|
|
82
|
+
diagnostics.append(issue('route_validation',error.motivo,field=error.campo,action=action))
|
|
83
|
+
except Exception:
|
|
84
|
+
diagnostics.append(issue('post_invalid','Review the selected platform block and contained media paths.',field='post',action='review_post'))
|
|
85
|
+
platforms[platform.value]={'status':'blocked' if diagnostics else 'passed','assets':assets,'issues':diagnostics,
|
|
86
|
+
'requirements_source':SOURCES.get(platform.value,'SocialCli configured route policy'),
|
|
87
|
+
'coverage':{'provider_acceptance_verified':False,'all_file_bytes_decoded':False,
|
|
88
|
+
'unchecked':['current account eligibility and grants','remote URL availability',
|
|
89
|
+
'full decoder integrity','provider processing and publication outcome'],
|
|
90
|
+
'local_policy':'Configured size/duration/route limits also apply; they are not a complete provider specification.'}}
|
|
91
|
+
passed=sum(row['status']=='passed' for row in platforms.values())
|
|
92
|
+
return {'version':1,'brand':brand.nombre,'slug':slug,'status':'passed' if passed==len(platforms) else 'partial' if passed else 'blocked',
|
|
93
|
+
'scope':'ordinary post.yml routes; native manifests, Stories and groups have separate validators',
|
|
94
|
+
'server_media_storage':False,'provider_eligibility_verified':False,'files_modified':False,'platforms':platforms}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def register(media_app,default_root):
|
|
98
|
+
import json
|
|
99
|
+
import typer
|
|
100
|
+
from socialctl.brands import cargar_brand
|
|
101
|
+
from socialctl.agent_errors import error_document
|
|
102
|
+
@media_app.command('check')
|
|
103
|
+
def check(slug:str,brand:str=typer.Option(...,'--brand'),root:Path=typer.Option(default_root,'--root'),only:list[Platform]=typer.Option(None,'--only'),json_output:bool=typer.Option(False,'--json')):
|
|
104
|
+
try:value=check_post(cargar_brand(root,brand),slug,only)
|
|
105
|
+
except (Exception,KeyboardInterrupt) as exc:
|
|
106
|
+
value={'version':1,'status':'error','error':error_document(exc,read_only=True)}
|
|
107
|
+
if json_output:typer.echo(json.dumps(value,ensure_ascii=False,indent=2))
|
|
108
|
+
elif 'platforms' in value:
|
|
109
|
+
typer.echo('Local post preflight; no upload or approval. Provider acceptance remains unverified.')
|
|
110
|
+
for platform,row in value['platforms'].items():
|
|
111
|
+
typer.echo(f"{platform}: {row['status']}")
|
|
112
|
+
for problem in row['issues']:typer.echo(f" {problem['field']}: {problem['message']}")
|
|
113
|
+
else:typer.echo(value['error']['message'])
|
|
114
|
+
if value['status']!='passed':raise typer.Exit(1)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Brand-bound supplied-byte bundles, independent of historical queue migrations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from socialctl.hosted_media import MAX_BYTES, MIMES, file_digest
|
|
17
|
+
from socialctl.migration_files import durable_write, rewrite_media, write_json
|
|
18
|
+
from socialctl.models import CampaignType
|
|
19
|
+
from socialctl.rutas import validar_ruta_relativa
|
|
20
|
+
from socialctl.scheduler import ScheduleStore
|
|
21
|
+
|
|
22
|
+
MAX_ITEMS = 64
|
|
23
|
+
MAX_TOTAL = 4 * MAX_BYTES
|
|
24
|
+
MAX_POST = 1024 * 1024
|
|
25
|
+
MAX_HEADER = 256 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MediaRegistryError(ValueError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def publication_gate(brand, slug: str):
|
|
34
|
+
"""Lock order: executor (if any), this gate, queue mutation, Meta gate."""
|
|
35
|
+
component(slug)
|
|
36
|
+
key = digest(canonical(dict(brand=brand.nombre, root=str(brand.raiz.resolve()), slug=slug)))
|
|
37
|
+
path = relative(brand.raiz, f".socialctl/media-post-gates/{key}.lock")
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
40
|
+
try:
|
|
41
|
+
try:
|
|
42
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
43
|
+
except BlockingIOError as exc:
|
|
44
|
+
raise MediaRegistryError("post is running concurrently; retry after it finishes") from exc
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
os.close(fd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def record_publication_start(brand, post, platforms) -> None:
|
|
51
|
+
if post.brand != brand.nombre:
|
|
52
|
+
raise MediaRegistryError("post belongs to another brand")
|
|
53
|
+
path = relative(brand.raiz, f".socialctl/media-publication-started/{component(post.slug)}.json")
|
|
54
|
+
# Evidence only for ingress draft eligibility, never a publication/retry gate.
|
|
55
|
+
write_json(path, dict(version=1, brand=brand.nombre, root=str(brand.raiz.resolve()),
|
|
56
|
+
slug=post.slug, platforms=[p.value for p in platforms]))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def digest(raw: bytes) -> str:
|
|
60
|
+
return hashlib.sha256(raw).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical(value: dict) -> bytes:
|
|
64
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_json(raw: bytes, *, limit=MAX_HEADER):
|
|
68
|
+
def unique(pairs):
|
|
69
|
+
result = {}
|
|
70
|
+
for key, value in pairs:
|
|
71
|
+
if key in result:
|
|
72
|
+
raise MediaRegistryError("duplicate JSON keys")
|
|
73
|
+
result[key] = value
|
|
74
|
+
return result
|
|
75
|
+
if len(raw) > limit:
|
|
76
|
+
raise MediaRegistryError("JSON exceeds the limit")
|
|
77
|
+
return json.loads(raw, object_pairs_hook=unique)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def safe(path: Path) -> Path:
|
|
81
|
+
"""Reject links even when they point within the allowed root."""
|
|
82
|
+
if any(part.is_symlink() for part in [path, *path.parents]):
|
|
83
|
+
raise MediaRegistryError("paths containing symbolic links are not supported")
|
|
84
|
+
if path.exists() and not (path.is_file() or path.is_dir()):
|
|
85
|
+
raise MediaRegistryError("unsupported file type")
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def relative(root: Path, value: str) -> Path:
|
|
90
|
+
try:
|
|
91
|
+
return safe(validar_ruta_relativa(root, value, MediaRegistryError, "media path"))
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise MediaRegistryError("invalid media path or symbolic link") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def component(value: str) -> str:
|
|
97
|
+
if not isinstance(value, str) or not re.fullmatch(r"[\w][\w .-]{0,127}", value) or value in {".", ".."}:
|
|
98
|
+
raise MediaRegistryError("invalid identifier")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_mime(path: Path, suffix: str) -> str:
|
|
103
|
+
mime = MIMES.get(suffix)
|
|
104
|
+
with safe(path).open("rb") as handle:
|
|
105
|
+
head = handle.read(32)
|
|
106
|
+
recognized = (
|
|
107
|
+
suffix in {".mp4", ".mov"} and len(head) >= 12 and head[4:8] == b"ftyp"
|
|
108
|
+
or suffix in {".jpg", ".jpeg"} and head.startswith(b"\xff\xd8\xff")
|
|
109
|
+
or suffix == ".png" and head.startswith(b"\x89PNG\r\n\x1a\n")
|
|
110
|
+
or suffix == ".webp" and head[:4] == b"RIFF" and head[8:12] == b"WEBP"
|
|
111
|
+
)
|
|
112
|
+
if not mime or not recognized:
|
|
113
|
+
raise MediaRegistryError("unsupported supplied MIME type or incompatible signature")
|
|
114
|
+
return mime
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_file(path: Path, item: dict) -> None:
|
|
118
|
+
safe(path)
|
|
119
|
+
if (not path.is_file() or path.stat().st_size != item["size"]
|
|
120
|
+
or file_digest(path) != item["sha256"]
|
|
121
|
+
or check_mime(path, Path(item["relative_path"]).suffix) != item["mime"]):
|
|
122
|
+
raise MediaRegistryError("existing bytes do not match SHA-256, size or MIME type")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def space(path: Path, size: int) -> None:
|
|
126
|
+
safe(path)
|
|
127
|
+
while not path.exists():
|
|
128
|
+
path = path.parent
|
|
129
|
+
if shutil.disk_usage(path).free < size + 1024 * 1024:
|
|
130
|
+
raise MediaRegistryError("insufficient destination space")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def copy_exact(incoming, out, size: int) -> None:
|
|
134
|
+
remaining = size
|
|
135
|
+
while remaining:
|
|
136
|
+
chunk = incoming.read(min(remaining, 1024 * 1024))
|
|
137
|
+
if not chunk or len(chunk) > remaining:
|
|
138
|
+
raise MediaRegistryError("supplied size changed during copying")
|
|
139
|
+
out.write(chunk)
|
|
140
|
+
remaining -= len(chunk)
|
|
141
|
+
if incoming.read(1):
|
|
142
|
+
raise MediaRegistryError("supplied size changed during copying")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def promote(source: Path, target: Path, item: dict, *, public: bool = False) -> None:
|
|
146
|
+
"""Copy into same-directory temporary inode, fsync then atomic no-clobber link."""
|
|
147
|
+
safe(target)
|
|
148
|
+
if target.exists():
|
|
149
|
+
check_file(target, item)
|
|
150
|
+
if public and target.stat().st_mode & 0o444 != 0o444:
|
|
151
|
+
raise MediaRegistryError("existing public copy is unreadable")
|
|
152
|
+
return
|
|
153
|
+
space(target.parent, item["size"])
|
|
154
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
if public:
|
|
156
|
+
# Only framework-owned content-address directories are made searchable.
|
|
157
|
+
for directory in (target.parent, target.parent.parent):
|
|
158
|
+
directory.chmod(directory.stat().st_mode | 0o555)
|
|
159
|
+
fd, temporary = tempfile.mkstemp(prefix=".ingress-", dir=target.parent)
|
|
160
|
+
temp = Path(temporary)
|
|
161
|
+
try:
|
|
162
|
+
with os.fdopen(fd, "wb") as out, safe(source).open("rb") as incoming:
|
|
163
|
+
copy_exact(incoming, out, item["size"])
|
|
164
|
+
out.flush()
|
|
165
|
+
os.fchmod(out.fileno(), 0o644 if public else 0o600)
|
|
166
|
+
os.fsync(out.fileno())
|
|
167
|
+
check_file(temp, item)
|
|
168
|
+
try:
|
|
169
|
+
os.link(temp, target)
|
|
170
|
+
except FileExistsError:
|
|
171
|
+
check_file(target, item)
|
|
172
|
+
ScheduleStore._sync_directory(target.parent)
|
|
173
|
+
finally:
|
|
174
|
+
temp.unlink(missing_ok=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def validate_manifest(brand, manifest: dict) -> dict:
|
|
178
|
+
try:
|
|
179
|
+
if set(manifest) != {"version", "brand", "slug", "post_sha256", "post_size", "assets", "digest"}:
|
|
180
|
+
raise ValueError()
|
|
181
|
+
unsigned = {k: v for k, v in manifest.items() if k != "digest"}
|
|
182
|
+
if (type(manifest["version"]) is not int or manifest["version"] != 1
|
|
183
|
+
or manifest["brand"] != brand.nombre or manifest["digest"] != digest(canonical(unsigned))):
|
|
184
|
+
raise ValueError()
|
|
185
|
+
component(manifest["slug"])
|
|
186
|
+
if not re.fullmatch(r"[a-f0-9]{64}", manifest["post_sha256"]):
|
|
187
|
+
raise ValueError()
|
|
188
|
+
if type(manifest["post_size"]) is not int or not 0 < manifest["post_size"] <= MAX_POST:
|
|
189
|
+
raise ValueError()
|
|
190
|
+
assets = manifest["assets"]
|
|
191
|
+
if not isinstance(assets, list) or not 0 < len(assets) <= MAX_ITEMS:
|
|
192
|
+
raise ValueError()
|
|
193
|
+
seen = set()
|
|
194
|
+
for item in assets:
|
|
195
|
+
if set(item) != {"sha256", "size", "mime", "source", "relative_path", "uses"}:
|
|
196
|
+
raise ValueError()
|
|
197
|
+
sha = item["sha256"]
|
|
198
|
+
if not re.fullmatch(r"[a-f0-9]{64}", sha):
|
|
199
|
+
raise ValueError()
|
|
200
|
+
suffix = Path(item["relative_path"]).suffix
|
|
201
|
+
if (item["relative_path"] != f"approved/sha256/{sha}{suffix}"
|
|
202
|
+
or item["mime"] != MIMES.get(suffix) or suffix not in MIMES
|
|
203
|
+
or type(item["size"]) is not int or not 0 < item["size"] <= MAX_BYTES
|
|
204
|
+
or item["relative_path"] in seen):
|
|
205
|
+
raise ValueError()
|
|
206
|
+
seen.add(item["relative_path"])
|
|
207
|
+
# Source is provenance only; ingestion never reads this client path.
|
|
208
|
+
if not item["source"].startswith("media/"):
|
|
209
|
+
raise ValueError()
|
|
210
|
+
validar_ruta_relativa(Path("/supplied"), item["source"], MediaRegistryError, "origen")
|
|
211
|
+
if (not isinstance(item["uses"], list) or not item["uses"] or len(item["uses"]) > 4
|
|
212
|
+
or len(set(item["uses"])) != len(item["uses"])
|
|
213
|
+
or any(use not in {manifest["slug"] + "/" + p for p in ("youtube", "facebook", "instagram", "tiktok")} for use in item["uses"])):
|
|
214
|
+
raise ValueError()
|
|
215
|
+
if sum(item["size"] for item in assets) > MAX_TOTAL:
|
|
216
|
+
raise ValueError()
|
|
217
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
218
|
+
raise MediaRegistryError("invalid or modified manifest, or manifest belongs to another brand") from exc
|
|
219
|
+
return manifest
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def bundle_path(brand, ident: str, suffix=".json") -> Path:
|
|
223
|
+
if not re.fullmatch(r"[a-f0-9]{64}", ident):
|
|
224
|
+
raise MediaRegistryError("invalid bundle identifier")
|
|
225
|
+
return relative(brand.raiz, f".socialctl/media-bundles/{ident}{suffix}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_bundle(brand, ident: str) -> tuple[dict, bytes]:
|
|
229
|
+
manifest = validate_manifest(brand, parse_json(bundle_path(brand, ident).read_bytes()))
|
|
230
|
+
post = bundle_path(brand, ident, ".yml").read_bytes()
|
|
231
|
+
if manifest["digest"] != ident or len(post) != manifest["post_size"] or digest(post) != manifest["post_sha256"]:
|
|
232
|
+
raise MediaRegistryError("bundle was modified")
|
|
233
|
+
return manifest, post
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def post_media(raw: bytes) -> dict[str, list[str]]:
|
|
237
|
+
try:
|
|
238
|
+
if len(raw) > MAX_POST:
|
|
239
|
+
raise ValueError()
|
|
240
|
+
data = yaml.safe_load(raw)
|
|
241
|
+
platforms = data["platforms"]
|
|
242
|
+
if not isinstance(platforms, dict) or not platforms or data.get("campaign") not in {v.value for v in CampaignType}:
|
|
243
|
+
raise ValueError()
|
|
244
|
+
result = {}
|
|
245
|
+
for name, block in platforms.items():
|
|
246
|
+
if name not in {"youtube", "facebook", "instagram", "tiktok"} or not isinstance(block["body"], str):
|
|
247
|
+
raise ValueError()
|
|
248
|
+
media = block.get("media", [])
|
|
249
|
+
if not isinstance(media, list) or any(not isinstance(value, str) for value in media):
|
|
250
|
+
raise ValueError()
|
|
251
|
+
result[name] = media
|
|
252
|
+
# Also rejects aliases, anchors and duplicate mapping keys.
|
|
253
|
+
rewrite_media(raw, {p: {v: v for v in values} for p, values in result.items() if values})
|
|
254
|
+
return result
|
|
255
|
+
except (KeyError, TypeError, ValueError, yaml.YAMLError) as exc:
|
|
256
|
+
raise MediaRegistryError("invalid supplied post") from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def stage(brand, slug: str) -> dict:
|
|
260
|
+
component(slug)
|
|
261
|
+
safe(brand.raiz)
|
|
262
|
+
path = relative(brand.raiz, f"posts/{slug}/post.yml")
|
|
263
|
+
if path.stat().st_size > MAX_POST:
|
|
264
|
+
raise MediaRegistryError("supplied post exceeds the limit")
|
|
265
|
+
raw = path.read_bytes()
|
|
266
|
+
uses = post_media(raw)
|
|
267
|
+
assets, replacements = {}, {}
|
|
268
|
+
for platform, values in uses.items():
|
|
269
|
+
replacements[platform] = {}
|
|
270
|
+
for name in values:
|
|
271
|
+
source = relative(brand.raiz / "media", name)
|
|
272
|
+
if not source.is_file():
|
|
273
|
+
raise MediaRegistryError("supplied file is missing")
|
|
274
|
+
size = source.stat().st_size
|
|
275
|
+
if not 0 < size <= MAX_BYTES:
|
|
276
|
+
raise MediaRegistryError("supplied size exceeds the limit")
|
|
277
|
+
suffix = source.suffix.lower()
|
|
278
|
+
mime = check_mime(source, suffix)
|
|
279
|
+
sha = file_digest(source)
|
|
280
|
+
relative_path = f"approved/sha256/{sha}{suffix}"
|
|
281
|
+
item = assets.setdefault(relative_path, dict(sha256=sha, size=size, mime=mime,
|
|
282
|
+
source="media/" + name, relative_path=relative_path, uses=[]))
|
|
283
|
+
use = f"{slug}/{platform}"
|
|
284
|
+
if use not in item["uses"]:
|
|
285
|
+
item["uses"].append(use)
|
|
286
|
+
replacements[platform][name] = relative_path
|
|
287
|
+
rewritten = rewrite_media(raw, {p: values for p, values in replacements.items() if values})
|
|
288
|
+
manifest = dict(version=1, brand=brand.nombre, slug=slug, post_sha256=digest(rewritten),
|
|
289
|
+
post_size=len(rewritten), assets=list(assets.values()))
|
|
290
|
+
manifest["digest"] = digest(canonical(manifest))
|
|
291
|
+
validate_manifest(brand, manifest)
|
|
292
|
+
space(brand.raiz, sum(item["size"] for item in assets.values()) + len(rewritten))
|
|
293
|
+
for item in assets.values():
|
|
294
|
+
promote(relative(brand.raiz, item["source"]), relative(brand.raiz / "media", item["relative_path"]), item)
|
|
295
|
+
target = bundle_path(brand, manifest["digest"])
|
|
296
|
+
if target.exists():
|
|
297
|
+
load_bundle(brand, manifest["digest"])
|
|
298
|
+
else:
|
|
299
|
+
durable_write(bundle_path(brand, manifest["digest"], ".yml"), rewritten)
|
|
300
|
+
write_json(target, manifest)
|
|
301
|
+
return manifest
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Checks for ordinary post.yml routes; no repair or provider access."""
|
|
2
|
+
from socialctl.models import MediaKind,Platform
|
|
3
|
+
|
|
4
|
+
CHROMA_420 = {'yuv420p','yuvj420p','nv12','nv21'} | {
|
|
5
|
+
f'yuv420p{depth}{endian}' for depth in (9,10,12,14,16) for endian in ('le','be')}
|
|
6
|
+
|
|
7
|
+
SOURCES={
|
|
8
|
+
'instagram':'https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/media',
|
|
9
|
+
'tiktok':'https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def technical_issues(platform,asset):
|
|
14
|
+
issues=[]
|
|
15
|
+
def issue(code,message):issues.append({'code':code,'action':'supply_compatible_media','retryable':False,'message':message})
|
|
16
|
+
if platform in {Platform.YOUTUBE,Platform.TIKTOK} and asset.kind is MediaKind.IMAGE:
|
|
17
|
+
issue('video_required','This SocialCli route uploads videos only; supply an existing video.')
|
|
18
|
+
if platform is Platform.INSTAGRAM and asset.width and asset.height:
|
|
19
|
+
ratio=(asset.probe or {}).get('display_ratio',asset.width/asset.height)
|
|
20
|
+
low,high=(0.8,1.91) if asset.kind is MediaKind.IMAGE else (0.01,10)
|
|
21
|
+
if ratio is None or not low <= ratio <= high:
|
|
22
|
+
issue('aspect_ratio','Instagram requires an image ratio from 4:5 to 1.91:1, or a Reel ratio from 0.01:1 to 10:1.')
|
|
23
|
+
if asset.probe is None:
|
|
24
|
+
return issues
|
|
25
|
+
meta=asset.probe
|
|
26
|
+
codec=meta['video_codec']
|
|
27
|
+
formats=set((meta['container'] or '').split(','))
|
|
28
|
+
if platform is Platform.INSTAGRAM and asset.kind is MediaKind.IMAGE:
|
|
29
|
+
if codec!='mjpeg' or asset.path.suffix.lower() not in {'.jpg','.jpeg'} or not formats & {'image2','jpeg_pipe'}:
|
|
30
|
+
issue('image_format','Instagram image publishing requires an existing JPEG image.')
|
|
31
|
+
if asset.kind is MediaKind.VIDEO and platform in {Platform.INSTAGRAM,Platform.TIKTOK}:
|
|
32
|
+
allowed={'h264','hevc'} if platform is Platform.INSTAGRAM else {'h264','hevc','vp8','vp9'}
|
|
33
|
+
if codec not in allowed:issue('video_codec','Supply video encoded with '+', '.join(sorted(allowed))+'.')
|
|
34
|
+
# format_name is a demuxer alias list, not proof of a container subtype.
|
|
35
|
+
brand=(meta.get('container_brand') or '').strip()
|
|
36
|
+
iso_brands={'isom','iso2','iso3','iso4','iso5','iso6','iso7','iso8','iso9','mp41','mp42','avc1','M4V','qt'}
|
|
37
|
+
verified_iso=bool(formats & {'mov','mp4'}) and brand in iso_brands and asset.path.suffix.lower() in {'.mov','.mp4'}
|
|
38
|
+
if not verified_iso:
|
|
39
|
+
issue('video_container','Container identity is unsupported or unverified; supply an existing verified MOV/MP4. Ambiguous Matroska/WebM identification is not accepted by this local check.')
|
|
40
|
+
if meta['fps'] is None or not 23 <= meta['fps'] <= 60:
|
|
41
|
+
issue('frame_rate','This publishing route requires a verified frame rate between 23 and 60 FPS.')
|
|
42
|
+
if platform is Platform.TIKTOK and any(not 360 <= v <= 4096 for v in (asset.width,asset.height)):
|
|
43
|
+
issue('dimensions','TikTok requires both video dimensions between 360 and 4096 pixels.')
|
|
44
|
+
if platform is Platform.INSTAGRAM:
|
|
45
|
+
if asset.width>1920:issue('dimensions','Instagram Reels allow at most 1920 horizontal pixels.')
|
|
46
|
+
if meta['pixel_format'] not in CHROMA_420:
|
|
47
|
+
issue('pixel_format','This route requires verified 4:2:0 chroma sampling.')
|
|
48
|
+
for audio in meta['audio']:
|
|
49
|
+
try:valid=audio['codec']=='aac' and 0<int(audio['sample_rate'])<=48000 and audio['channels'] in {1,2}
|
|
50
|
+
except (TypeError,ValueError):valid=False
|
|
51
|
+
if not valid:issue('audio_format','Instagram Reels require AAC audio, at most 48 kHz and one or two channels.')
|
|
52
|
+
return issues
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Read-only network metrics package; imports register available readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from socialctl.metricas import facebook, instagram, tiktok, youtube # noqa: F401
|
|
6
|
+
from socialctl.metricas.base import LECTORES, Lector, SinPermiso, leer_red # noqa: F401
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Store one brand snapshot per day; historical files can be read manually."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import warnings
|
|
9
|
+
from datetime import date, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from socialctl.brands import Brand
|
|
13
|
+
from socialctl.metricas.modelos import Snapshot
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def escribir_atomico(ruta: Path, texto: str) -> Path:
|
|
17
|
+
"""Atomically replace file using a temporary sibling so interruptions never leave a partially written destination."""
|
|
18
|
+
ruta.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
descriptor, temporal = tempfile.mkstemp(
|
|
20
|
+
dir=ruta.parent, prefix=f".{ruta.name}.", suffix=".tmp"
|
|
21
|
+
)
|
|
22
|
+
try:
|
|
23
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as fichero:
|
|
24
|
+
fichero.write(texto)
|
|
25
|
+
fichero.flush()
|
|
26
|
+
os.fsync(fichero.fileno())
|
|
27
|
+
os.replace(temporal, ruta)
|
|
28
|
+
except BaseException:
|
|
29
|
+
# Si algo falló antes del `replace`, el destino sigue intacto; lo
|
|
30
|
+
# único que sobra es el temporal, que no debe quedarse en la carpeta
|
|
31
|
+
# del usuario.
|
|
32
|
+
Path(temporal).unlink(missing_ok=True)
|
|
33
|
+
raise
|
|
34
|
+
return ruta
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dir_metricas(brand: Brand) -> Path:
|
|
38
|
+
return brand.raiz / "metricas"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ruta_snapshot(brand: Brand, fecha: date) -> Path:
|
|
42
|
+
return dir_metricas(brand) / f"{fecha.isoformat()}.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def guardar_snapshot(brand: Brand, snapshot: Snapshot) -> Path:
|
|
46
|
+
"""Write daily snapshot atomically."""
|
|
47
|
+
return escribir_atomico(
|
|
48
|
+
ruta_snapshot(brand, snapshot.fecha),
|
|
49
|
+
snapshot.model_dump_json(indent=2) + "\n",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cargar_snapshot(brand: Brand, fecha: date) -> Snapshot | None:
|
|
54
|
+
"""Load dated snapshot, or None if absent/unreadable; warn on corrupt files rather than abort all metrics."""
|
|
55
|
+
from socialctl.youtube_data import enforce_youtube_data
|
|
56
|
+
enforce_youtube_data(brand.raiz)
|
|
57
|
+
ruta = ruta_snapshot(brand, fecha)
|
|
58
|
+
if not ruta.is_file():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return Snapshot.model_validate(json.loads(ruta.read_text(encoding="utf-8")))
|
|
62
|
+
except Exception as exc: # noqa: BLE001 - cualquier forma ilegible cuenta
|
|
63
|
+
warnings.warn(
|
|
64
|
+
f"snapshot {ruta} cannot be read ({type(exc).__name__}: {exc}); "
|
|
65
|
+
"ignored, so no comparison with that day is available today. A "
|
|
66
|
+
"snapshot can be regenerated: delete it and rerun stats for "
|
|
67
|
+
"today's date, or repair it manually to preserve history.",
|
|
68
|
+
stacklevel=2,
|
|
69
|
+
)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fechas(brand: Brand) -> list[date]:
|
|
74
|
+
"""List existing snapshot dates oldest first; ignore non-date JSON filenames."""
|
|
75
|
+
carpeta = dir_metricas(brand)
|
|
76
|
+
if not carpeta.is_dir():
|
|
77
|
+
return []
|
|
78
|
+
fechas = []
|
|
79
|
+
for fichero in carpeta.glob("*.json"):
|
|
80
|
+
try:
|
|
81
|
+
fechas.append(date.fromisoformat(fichero.stem))
|
|
82
|
+
except ValueError:
|
|
83
|
+
continue
|
|
84
|
+
return sorted(fechas)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ultimo_snapshot(brand: Brand) -> Snapshot | None:
|
|
88
|
+
fechas = _fechas(brand)
|
|
89
|
+
return cargar_snapshot(brand, fechas[-1]) if fechas else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def snapshot_anterior(brand: Brand, antes_de: date) -> Snapshot | None:
|
|
93
|
+
previas = [f for f in _fechas(brand) if f < antes_de]
|
|
94
|
+
return cargar_snapshot(brand, previas[-1]) if previas else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Cuánto histórico de snapshots se conserva. Decisión del usuario, literal:
|
|
98
|
+
#: un año, sin adelgazar los ficheros intermedios -160 MB anuales es un
|
|
99
|
+
#: coste aceptable y la simplicidad vale más que ahorrar unos megas-.
|
|
100
|
+
#:
|
|
101
|
+
#: **Por qué 365 días corridos y no el año natural** (1 de enero a 31 de
|
|
102
|
+
#: diciembre): el año natural haría que el tamaño de la carpeta dependiera
|
|
103
|
+
#: de qué día del año es hoy -el 2 de enero se quedaría con apenas dos
|
|
104
|
+
#: días de histórico, aunque ayer mismo hubiera un año entero-, que es
|
|
105
|
+
#: justo lo contrario de predecible para quien mire la carpeta. Con una
|
|
106
|
+
#: ventana corrida, la carpeta contiene siempre (ejecutando `stats` a
|
|
107
|
+
#: diario) el mismo número de ficheros aproximado y el mismo peso
|
|
108
|
+
#: aproximado, sea hoy el día que sea del calendario; y "un año" significa
|
|
109
|
+
#: lo mismo que en la frase del usuario: 365 días hacia atrás desde hoy,
|
|
110
|
+
#: sin sorpresas de fin de año.
|
|
111
|
+
RETENCION_SNAPSHOTS_DIAS = 365
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def limpiar_snapshots_antiguos(
|
|
115
|
+
brand: Brand, hoy: date | None = None
|
|
116
|
+
) -> tuple[list[Path], list[tuple[Path, OSError]]]:
|
|
117
|
+
"""Delete snapshots older than one year; leave piezas.yml and resumen.md unchanged."""
|
|
118
|
+
hoy = hoy if hoy is not None else date.today()
|
|
119
|
+
corte = hoy - timedelta(days=RETENCION_SNAPSHOTS_DIAS)
|
|
120
|
+
|
|
121
|
+
borrados: list[Path] = []
|
|
122
|
+
fallidos: list[tuple[Path, OSError]] = []
|
|
123
|
+
for fecha in _fechas(brand):
|
|
124
|
+
if fecha >= corte:
|
|
125
|
+
continue
|
|
126
|
+
ruta = ruta_snapshot(brand, fecha)
|
|
127
|
+
try:
|
|
128
|
+
ruta.unlink()
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
fallidos.append((ruta, exc))
|
|
131
|
+
else:
|
|
132
|
+
borrados.append(ruta)
|
|
133
|
+
return borrados, fallidos
|