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,304 @@
|
|
|
1
|
+
"""Bounded account reads and fixed Analytics transport; no credential refresh."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from socialctl.identity import credential_metadata
|
|
11
|
+
from socialctl.management.youtube_owned import YouTubeOwnedClient
|
|
12
|
+
from socialctl.management.youtube_resources import API, ResourceError, ResourceRejected, ResourceUncertain, provider_reason, resource_id
|
|
13
|
+
from socialctl.metricas.analytics_schema import ReportQuery, parse_query_response, provenance, metric_value
|
|
14
|
+
from socialctl.models import Platform
|
|
15
|
+
|
|
16
|
+
ANALYTICS = "https://youtubeanalytics.googleapis.com/v2"
|
|
17
|
+
REPORTING = "https://youtubereporting.googleapis.com/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def opaque(value):
|
|
21
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 512 or any(ord(c) < 33 or ord(c) == 127 for c in value):
|
|
22
|
+
raise ResourceError("invalid opaque provider ID")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encoded(value):
|
|
27
|
+
return quote(opaque(value), safe="")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def complete(result):
|
|
31
|
+
if not result["complete"]:
|
|
32
|
+
raise ResourceError("incomplete provider list; absence/ownership not established")
|
|
33
|
+
return result["items"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def unsigned_long(value):
|
|
37
|
+
"""Exact documented unsigned long; preserve caller's raw wire value."""
|
|
38
|
+
if type(value) is int:
|
|
39
|
+
number = value
|
|
40
|
+
elif isinstance(value, str) and re.fullmatch(r"[0-9]{1,20}", value):
|
|
41
|
+
number = int(value)
|
|
42
|
+
else:
|
|
43
|
+
raise ResourceError("unsigned long schema invalid")
|
|
44
|
+
if not 0 <= number <= 18446744073709551615:
|
|
45
|
+
raise ResourceError("unsigned long outside uint64 range")
|
|
46
|
+
return number
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def failure(exc):
|
|
50
|
+
if exc.http_status == 400:
|
|
51
|
+
return "provider_rejected_report_combination"
|
|
52
|
+
if exc.http_status == 429 or exc.provider_reason == "quotaExceeded":
|
|
53
|
+
return "rate_limited"
|
|
54
|
+
if exc.http_status in {401, 403}:
|
|
55
|
+
return "forbidden_or_missing_grant"
|
|
56
|
+
return "provider_or_schema_unavailable"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AnalyticsClient(YouTubeOwnedClient):
|
|
60
|
+
def _token(self):
|
|
61
|
+
if self._access_token is None:
|
|
62
|
+
try:
|
|
63
|
+
secret = self.brand.leer_secreto(Platform.YOUTUBE)
|
|
64
|
+
self.credential_observation = credential_metadata(secret)
|
|
65
|
+
if secret.get("auth_mode") == "broker":
|
|
66
|
+
from socialctl.connections.client import broker_access_token
|
|
67
|
+
token = broker_access_token(self.brand, Platform.YOUTUBE, self.client, refresh=False)
|
|
68
|
+
else:
|
|
69
|
+
token = secret.get("access_token")
|
|
70
|
+
except Exception:
|
|
71
|
+
raise ResourceError("credentials unavailable") from None
|
|
72
|
+
if self.credential_observation["expiry"]["expired"]:
|
|
73
|
+
raise ResourceError("credentials expired; no implicit refresh")
|
|
74
|
+
if not isinstance(token, str) or not token:
|
|
75
|
+
raise ResourceError("credentials unavailable")
|
|
76
|
+
self._access_token = token
|
|
77
|
+
return self._access_token
|
|
78
|
+
|
|
79
|
+
def require_scope(self, scopes):
|
|
80
|
+
self._token()
|
|
81
|
+
actual = self.credential_observation["granted_scopes"]
|
|
82
|
+
if actual["state"] != "known" or not set(actual["values"]) & set(scopes):
|
|
83
|
+
raise ResourceError("required granted scope not recorded; no new grant or refresh attempted")
|
|
84
|
+
|
|
85
|
+
def _endpoint_allowed(self, method, path):
|
|
86
|
+
return method == "GET" and path in {"/youtube/v3/channels", "/youtube/v3/videos", "/youtube/v3/playlists", "/youtube/v3/videos:batchGetStats"}
|
|
87
|
+
|
|
88
|
+
def service_request(self, method, url, *, max_bytes=4*1024*1024, **kwargs):
|
|
89
|
+
parsed = httpx.URL(url)
|
|
90
|
+
paths = {("GET", "/v2/reports"), *( (m, "/v2/" + r) for r in ("groups", "groupItems") for m in ("GET", "POST", "PUT", "DELETE") if not (r == "groupItems" and m == "PUT") )}
|
|
91
|
+
reporting = ((method, parsed.path) in {("GET", "/v1/reportTypes"), ("GET", "/v1/jobs"), ("POST", "/v1/jobs")}
|
|
92
|
+
or method in {"GET", "DELETE"} and bool(re.fullmatch(r"/v1/jobs/[^/]+", parsed.path))
|
|
93
|
+
or method == "GET" and bool(re.fullmatch(r"/v1/jobs/[^/]+/reports(?:/[^/]+)?", parsed.path)))
|
|
94
|
+
allowed = (parsed.host == "youtubeanalytics.googleapis.com" and (method, parsed.path) in paths
|
|
95
|
+
or parsed.host == "youtubereporting.googleapis.com" and reporting
|
|
96
|
+
or parsed.host == "www.googleapis.com" and method == "GET" and parsed.path == "/youtube/v3/videos:batchGetStats")
|
|
97
|
+
if not allowed or parsed.scheme != "https" or parsed.port not in {None, 443} or parsed.userinfo or parsed.query or parsed.fragment:
|
|
98
|
+
raise ResourceError("endpoint not allowed")
|
|
99
|
+
return self._bounded_request(method, url, max_bytes=max_bytes, **kwargs)
|
|
100
|
+
|
|
101
|
+
def _bounded_request(self, method, url, *, max_bytes, **kwargs):
|
|
102
|
+
token = self._token()
|
|
103
|
+
writing = method != "GET"
|
|
104
|
+
deadline = time.monotonic() + 120
|
|
105
|
+
try:
|
|
106
|
+
with self.client.stream(method, url, headers={"Authorization": f"Bearer {token}", "Accept-Encoding": "identity"},
|
|
107
|
+
follow_redirects=False, timeout=60, **kwargs) as response:
|
|
108
|
+
if not response.is_success:
|
|
109
|
+
error = (ResourceUncertain if response.is_redirect or response.status_code >= 500 else ResourceRejected) if writing else ResourceError
|
|
110
|
+
reason = provider_reason(response, token)
|
|
111
|
+
raise error(f"provider HTTP {response.status_code}; effect unconfirmed", http_status=response.status_code, provider_reason=reason)
|
|
112
|
+
if response.headers.get("content-encoding", "identity") != "identity":
|
|
113
|
+
raise (ResourceUncertain if writing else ResourceError)("compressed response not accepted")
|
|
114
|
+
if not writing and response.status_code != 200:
|
|
115
|
+
raise ResourceError("partial/nonstandard read response not accepted")
|
|
116
|
+
length = response.headers.get("content-length")
|
|
117
|
+
if length is not None and (not re.fullmatch(r"[0-9]{1,12}", length) or int(length) > max_bytes):
|
|
118
|
+
raise (ResourceUncertain if writing else ResourceError)("invalid response length or byte limit exceeded")
|
|
119
|
+
raw = bytearray()
|
|
120
|
+
for chunk in response.iter_bytes(chunk_size=65536):
|
|
121
|
+
if len(raw)+len(chunk) > max_bytes or time.monotonic() > deadline:
|
|
122
|
+
raise (ResourceUncertain if writing else ResourceError)("response byte limit")
|
|
123
|
+
raw.extend(chunk)
|
|
124
|
+
if length is not None and len(raw) != int(length):
|
|
125
|
+
raise (ResourceUncertain if writing else ResourceError)("partial response length mismatch")
|
|
126
|
+
return httpx.Response(response.status_code, content=bytes(raw), request=response.request)
|
|
127
|
+
except ResourceError:
|
|
128
|
+
raise
|
|
129
|
+
except Exception:
|
|
130
|
+
raise (ResourceUncertain if writing else ResourceError)("provider transport interrupted") from None
|
|
131
|
+
|
|
132
|
+
def pages(self, url, key="items", params=None, max_pages=100, paginated=True):
|
|
133
|
+
if type(max_pages) is not int or not 1 <= max_pages <= 100:
|
|
134
|
+
raise ResourceError("page bound 1..100")
|
|
135
|
+
items, seen, ids = [], set(), set()
|
|
136
|
+
params = dict(params or {})
|
|
137
|
+
result = {"items": items, "complete": False, "failure_reason": None, "pages": 0}
|
|
138
|
+
for _ in range(max_pages):
|
|
139
|
+
try:
|
|
140
|
+
payload = self._json(self.service_request("GET", url, params=params))
|
|
141
|
+
if key not in payload and (key == "items" or set(payload)-{"nextPageToken"}):
|
|
142
|
+
raise ResourceError("list envelope schema missing collection")
|
|
143
|
+
rows = payload.get(key, [])
|
|
144
|
+
if not isinstance(rows, list) or len(rows) > 1000 or len(items)+len(rows) > 100000:
|
|
145
|
+
raise ResourceError("invalid/oversized provider page")
|
|
146
|
+
page_ids = [opaque(r.get("id")) if isinstance(r, dict) else opaque(None) for r in rows]
|
|
147
|
+
if len(page_ids) != len(set(page_ids)) or set(page_ids) & ids:
|
|
148
|
+
raise ResourceError("duplicate identity across provider pages")
|
|
149
|
+
token = payload.get("nextPageToken")
|
|
150
|
+
if "nextPageToken" in payload and (not paginated or not isinstance(token, str) or not token or len(token) > 4096 or token in seen):
|
|
151
|
+
raise ResourceError("invalid/repeated pagination token")
|
|
152
|
+
items.extend(rows)
|
|
153
|
+
ids.update(page_ids)
|
|
154
|
+
result["pages"] += 1
|
|
155
|
+
if token is None:
|
|
156
|
+
result["complete"] = True
|
|
157
|
+
return result
|
|
158
|
+
seen.add(token)
|
|
159
|
+
params["pageToken"] = token
|
|
160
|
+
except ResourceError as exc:
|
|
161
|
+
result["failure_reason"] = failure(exc)
|
|
162
|
+
return result
|
|
163
|
+
result["failure_reason"] = "page_limit"
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def groups(self, max_pages=100):
|
|
167
|
+
self.identity()
|
|
168
|
+
result = self.pages(f"{ANALYTICS}/groups", params={"mine": "true"}, max_pages=max_pages)
|
|
169
|
+
for row in result["items"]:
|
|
170
|
+
if not isinstance(row.get("snippet"), dict) or not isinstance(row["snippet"].get("title"), str) or not isinstance(row.get("contentDetails"), dict) or row["contentDetails"].get("itemType") not in {"youtube#video", "youtube#channel", "youtube#playlist", "youtubePartner#asset"}:
|
|
171
|
+
raise ResourceError("group schema invalid")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
def group(self, group_id):
|
|
175
|
+
rows = [r for r in complete(self.groups()) if r["id"] == opaque(group_id)]
|
|
176
|
+
if len(rows) != 1:
|
|
177
|
+
raise ResourceError("group not verified in authenticated mine membership")
|
|
178
|
+
return rows[0]
|
|
179
|
+
|
|
180
|
+
def group_items(self, group_id):
|
|
181
|
+
self.group(group_id)
|
|
182
|
+
result = self.pages(f"{ANALYTICS}/groupItems", params={"groupId": opaque(group_id)}, paginated=False)
|
|
183
|
+
for row in result["items"]:
|
|
184
|
+
if row.get("groupId") != group_id or not isinstance(row.get("resource"), dict):
|
|
185
|
+
raise ResourceError("groupItems identity mismatch")
|
|
186
|
+
opaque(row["resource"].get("id"))
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def bind_group_resource(self, group, rid):
|
|
190
|
+
kind = group["contentDetails"]["itemType"]
|
|
191
|
+
if kind == "youtube#video":
|
|
192
|
+
return self.one("videos", resource_id(rid), parts="snippet")
|
|
193
|
+
if kind == "youtube#channel" and rid == self.identity():
|
|
194
|
+
return {"id": rid}
|
|
195
|
+
if kind == "youtube#playlist":
|
|
196
|
+
return self.one("playlists", resource_id(rid))
|
|
197
|
+
raise ResourceError("resource not owned or content-owner asset unsupported")
|
|
198
|
+
|
|
199
|
+
def query(self, query):
|
|
200
|
+
query = ReportQuery.model_validate(query.model_dump())
|
|
201
|
+
account = self.identity()
|
|
202
|
+
for vid in query.video_ids:
|
|
203
|
+
self.one("videos", vid, parts="snippet")
|
|
204
|
+
for playlist in query.playlist_ids:
|
|
205
|
+
self.one("playlists", playlist)
|
|
206
|
+
if query.group_id:
|
|
207
|
+
self.group(query.group_id)
|
|
208
|
+
result = {"query": query.model_dump(mode="json"), "rows": [], "complete": False, "failure_reason": None,
|
|
209
|
+
"provenance": provenance("youtubeAnalytics.reports.query", account), "pages": 0}
|
|
210
|
+
result["provenance"]["dimensions"] = query.dimensions
|
|
211
|
+
result["provenance"]["paid_organic_eligibility"] = query.audience_type or "report_metric_dependent_not_inferred"
|
|
212
|
+
result["provenance"]["credential_observation"] = self.credential_observation
|
|
213
|
+
params, seen = query.params(), set()
|
|
214
|
+
for _ in range(query.max_pages):
|
|
215
|
+
try:
|
|
216
|
+
payload = self._json(self.service_request("GET", f"{ANALYTICS}/reports", params=params))
|
|
217
|
+
rows = parse_query_response(query, payload)
|
|
218
|
+
keys = [json.dumps(r["dimensions"], sort_keys=True) for r in rows]
|
|
219
|
+
if len(keys) != len(set(keys)) or set(keys) & seen:
|
|
220
|
+
raise ResourceError("duplicate dimensions across pages")
|
|
221
|
+
result["rows"].extend(rows)
|
|
222
|
+
seen.update(keys)
|
|
223
|
+
result["pages"] += 1
|
|
224
|
+
if len(rows) < query.max_results:
|
|
225
|
+
result["complete"] = True
|
|
226
|
+
break
|
|
227
|
+
params["startIndex"] += len(rows)
|
|
228
|
+
except ResourceError as exc:
|
|
229
|
+
result["failure_reason"] = failure(exc)
|
|
230
|
+
break
|
|
231
|
+
if not result["complete"] and result["failure_reason"] is None:
|
|
232
|
+
result["failure_reason"] = "page_limit"
|
|
233
|
+
if not result["rows"]:
|
|
234
|
+
result["missing_metrics"] = {m: {"value": None, "missing_reason": result["failure_reason"] or "no_rows_or_suppressed"} for m in query.metrics}
|
|
235
|
+
days = [r["dimensions"]["day"] for r in result["rows"] if "day" in r["dimensions"]]
|
|
236
|
+
result["provenance"]["returned_date_coverage"] = {"first": min(days) if days else None, "last": max(days) if days else None,
|
|
237
|
+
"observed_days": len(set(days)), "limitation": "pagination complete does not prove all requested dates available"}
|
|
238
|
+
result["provenance"]["metric_definitions"] = {m: {"url": "https://developers.google.com/youtube/analytics/metrics#" + m,
|
|
239
|
+
"aggregation": "official returned value; no local aggregation", "unit": {"estimatedMinutesWatched": "minutes", "estimatedRedMinutesWatched": "minutes",
|
|
240
|
+
"averageViewDuration": "seconds", "averageViewPercentage": "percentage", "viewerPercentage": "percentage"}.get(m, "see exact provider metric definition")} for m in query.metrics}
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def compare(self, end_date, metrics=None, video_ids=None):
|
|
244
|
+
result = {"source": "official_period_values_side_by_side", "windows": [], "causal_claim": False,
|
|
245
|
+
"confounders": ["publication cadence", "audience mix", "paid traffic", "seasonality", "provider latency/suppression", "definition changes"],
|
|
246
|
+
"aggregation": "none; no summed rates or unweighted means"}
|
|
247
|
+
metrics = list(dict.fromkeys([*(metrics or ["views", "averageViewDuration"]), "views"]))
|
|
248
|
+
for days in (7, 28):
|
|
249
|
+
current = self.query(ReportQuery(start_date=end_date-timedelta(days=days-1), end_date=end_date, metrics=metrics, video_ids=video_ids or []))
|
|
250
|
+
prior = self.query(ReportQuery(start_date=end_date-timedelta(days=2*days-1), end_date=end_date-timedelta(days=days), metrics=metrics, video_ids=video_ids or []))
|
|
251
|
+
result["windows"].append({"days": days, "current": current, "previous": prior,
|
|
252
|
+
"sample_size": {"unit": "provider views, not unique viewers", "current": self._sample(current), "previous": self._sample(prior)}})
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _sample(result):
|
|
257
|
+
return result["rows"][0]["metrics"].get("views") if result["complete"] and len(result["rows"]) == 1 else {"value": None, "missing_reason": "incomplete_or_unavailable"}
|
|
258
|
+
|
|
259
|
+
def batch_stats(self, video_ids, parts=None):
|
|
260
|
+
parts = parts or ["id", "statistics"]
|
|
261
|
+
if not isinstance(video_ids, list) or not 1 <= len(video_ids) <= 50 or len(set(video_ids)) != len(video_ids) or not isinstance(parts, list) or not parts or set(parts)-{"id", "snippet", "statistics", "contentDetails"}:
|
|
262
|
+
raise ResourceError("batch requires 1..50 distinct owned IDs and documented parts (local bound)")
|
|
263
|
+
account = self.identity()
|
|
264
|
+
for vid in video_ids:
|
|
265
|
+
self.one("videos", resource_id(vid), parts="snippet")
|
|
266
|
+
payload = self._json(self.service_request("GET", f"{API}/videos:batchGetStats", params={"id": ",".join(video_ids), "part": ",".join(parts)}))
|
|
267
|
+
rows, summary = payload.get("items"), payload.get("summary")
|
|
268
|
+
has_summary = "summary" in payload
|
|
269
|
+
if not isinstance(rows, list) or has_summary and not isinstance(summary, dict):
|
|
270
|
+
raise ResourceError("batch stats invalid envelope")
|
|
271
|
+
ids = [r.get("id") if isinstance(r, dict) else None for r in rows]
|
|
272
|
+
if any(not isinstance(v, str) for v in ids) or len(set(ids)) != len(ids) or set(ids)-set(video_ids):
|
|
273
|
+
raise ResourceError("batch stats returned duplicate, missing or unexpected identity")
|
|
274
|
+
# Actual responses can omit the documented summary entirely. Returned
|
|
275
|
+
# exact IDs still establish their own values; absence of another ID does
|
|
276
|
+
# not establish provider failure. Explicit null/malformed summaries fail.
|
|
277
|
+
failed = summary.get("failedVideoIds", []) if has_summary else []
|
|
278
|
+
if has_summary and (not isinstance(failed, list) or any(not isinstance(v, str) for v in failed)
|
|
279
|
+
or len(set(ids+failed)) != len(ids+failed) or set(ids+failed) != set(video_ids)
|
|
280
|
+
or any(unsigned_long(summary.get(k)) != n for k, n in {"requestedVideoCount": len(video_ids), "succeededVideoCount": len(rows), "failedVideoCount": len(failed)}.items())):
|
|
281
|
+
raise ResourceError("batch stats exact success/failure IDs or counts mismatch")
|
|
282
|
+
unaccounted = [v for v in video_ids if v not in set(ids+failed)]
|
|
283
|
+
p = provenance("youtube.videos.batchGetStats", account)
|
|
284
|
+
p.update({"credential_observation": self.credential_observation, "provider_summary_present": has_summary,
|
|
285
|
+
"partition_provenance": "returned ID membership checked locally; missing IDs without summary have unknown provider outcomes",
|
|
286
|
+
"period": "cumulative_snapshot_not_Analytics", "definitions_url": "https://developers.google.com/youtube/v3/docs/videos#statistics.viewCount",
|
|
287
|
+
"viewCount_definition_effective": "2026-08-24", "viewCount_definition": "playback-start counting across formats per resource docs; do not apply to Analytics"})
|
|
288
|
+
outcomes = {v: {"value": None, "missing_reason": "provider_failed_id"} for v in failed}
|
|
289
|
+
outcomes.update({v: {"value": None, "missing_reason": "provider_outcome_unaccounted"} for v in unaccounted})
|
|
290
|
+
for row in rows:
|
|
291
|
+
item = {"value": row, "missing_reason": None}
|
|
292
|
+
if "statistics" in parts:
|
|
293
|
+
stats = row.get("statistics", {})
|
|
294
|
+
if not isinstance(stats, dict):
|
|
295
|
+
raise ResourceError("batch statistics schema invalid")
|
|
296
|
+
item["statistics"] = {}
|
|
297
|
+
for name in ("viewCount", "likeCount", "commentCount"):
|
|
298
|
+
value = stats.get(name)
|
|
299
|
+
item["statistics"][name] = metric_value(unsigned_long(value) if value is not None else None, "provider_field_absent")
|
|
300
|
+
outcomes[row["id"]] = item
|
|
301
|
+
return {"outcomes": outcomes, "summary": summary, "complete": not unaccounted,
|
|
302
|
+
"provider_summary": {"value": summary, "missing_reason": None if has_summary else "provider_summary_omitted"},
|
|
303
|
+
"observed_partition": {"source": "local_exact_id_validation", "returned_ids": ids,
|
|
304
|
+
"provider_failed_ids": failed if has_summary else None, "unaccounted_ids": unaccounted}, "provenance": p}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Bounded lossless CSV parsing and crash-safe version/active-period checkpoints."""
|
|
2
|
+
import csv
|
|
3
|
+
from datetime import date
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from socialctl.identity import credential_metadata
|
|
16
|
+
from socialctl.management.resource_changes import ResourceStore, digest, save_download
|
|
17
|
+
from socialctl.management.youtube_resources import ResourceError
|
|
18
|
+
from socialctl.metricas.analytics_client import opaque
|
|
19
|
+
from socialctl.metricas.analytics_schema import metric_value, provenance
|
|
20
|
+
from socialctl.metricas.reporting_client import MAX_CSV, instant, report_period
|
|
21
|
+
|
|
22
|
+
DIMENSIONS = set("date channel_id video_id playlist_id country_code province_code playback_location_type playback_location_detail live_or_on_demand subscribed_status traffic_source_type traffic_source_detail device_type operating_system age_group gender sharing_service annotation_type annotation_id card_type card_id end_screen_element_type end_screen_element_id subtitle_language subtitle_language_autotranslated".split())
|
|
23
|
+
COUNTS = set("views engaged_views comments likes dislikes shares subscribers_gained subscribers_lost videos_added_to_playlists videos_removed_from_playlists card_clicks card_impressions card_teaser_clicks card_teaser_impressions annotation_clicks annotation_impressions annotation_closable_impressions annotation_clickable_impressions annotation_closes end_screen_element_clicks end_screen_element_impressions".split())
|
|
24
|
+
COUNTS |= {"red_views", "video_thumbnail_impressions"}
|
|
25
|
+
FLOATS = {"watch_time_minutes": "minutes", "average_view_duration_seconds": "seconds", "average_view_duration_percentage": "percentage",
|
|
26
|
+
"annotation_click_through_rate": "rate", "annotation_close_rate": "rate", "card_click_rate": "rate", "card_teaser_click_rate": "rate",
|
|
27
|
+
"end_screen_element_click_rate": "rate", "views_percentage": "percentage", "red_watch_time_minutes": "minutes", "video_thumbnail_impressions_ctr": "percentage"}
|
|
28
|
+
DEFINITIONS = "https://developers.google.com/youtube/reporting/v1/reports/"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StudioMetadata(BaseModel):
|
|
32
|
+
"""Normalize JSON strings and safe_load's native YAML date/time values."""
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
source: Literal["youtubeStudio"]
|
|
35
|
+
account_id: str
|
|
36
|
+
export_id: str
|
|
37
|
+
start_date: date
|
|
38
|
+
end_date: date
|
|
39
|
+
exported_at: AwareDatetime
|
|
40
|
+
source_filename: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_supplied(path, maximum=MAX_CSV):
|
|
44
|
+
try:
|
|
45
|
+
fd = os.open(Path(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
46
|
+
with os.fdopen(fd, "rb") as stream:
|
|
47
|
+
before = os.fstat(stream.fileno())
|
|
48
|
+
if not stat.S_ISREG(before.st_mode) or not 0 < before.st_size <= maximum:
|
|
49
|
+
raise ResourceError("supplied file must be bounded nonempty regular file")
|
|
50
|
+
data = stream.read(maximum+1)
|
|
51
|
+
after = os.fstat(stream.fileno())
|
|
52
|
+
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or len(data) != before.st_size:
|
|
53
|
+
raise ResourceError("supplied file changed during read")
|
|
54
|
+
return data
|
|
55
|
+
except OSError:
|
|
56
|
+
raise ResourceError("cannot read supplied file without following symlinks") from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_csv(data, binding):
|
|
60
|
+
if not isinstance(data, bytes) or not 0 < len(data) <= MAX_CSV:
|
|
61
|
+
raise ResourceError("CSV byte limit")
|
|
62
|
+
try:
|
|
63
|
+
text = data.decode("utf-8-sig")
|
|
64
|
+
if "\x00" in text:
|
|
65
|
+
raise ValueError()
|
|
66
|
+
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
|
|
67
|
+
headers = next(reader)
|
|
68
|
+
if not headers or len(headers) > 256 or len(set(headers)) != len(headers) or any(not h or len(h) > 256 for h in headers):
|
|
69
|
+
raise ValueError()
|
|
70
|
+
reporting = binding["source"] == "youtubeReporting"
|
|
71
|
+
if reporting:
|
|
72
|
+
start, end = report_period(binding["report"])
|
|
73
|
+
if "date" not in headers:
|
|
74
|
+
raise ResourceError("schema: report has no date; raw download may be saved separately")
|
|
75
|
+
else:
|
|
76
|
+
start, end = date.fromisoformat(binding["start_date"]), date.fromisoformat(binding["end_date"])
|
|
77
|
+
rows, seen = [], set()
|
|
78
|
+
known = DIMENSIONS | COUNTS | set(FLOATS) if reporting else set()
|
|
79
|
+
columns = {h: {"kind": "dimension" if h in DIMENSIONS and reporting else "metric" if h in COUNTS|set(FLOATS) and reporting else "unknown",
|
|
80
|
+
"semantics_source": "official_catalog_2026-09-13" if h in known else "unknown",
|
|
81
|
+
"definition_url": DEFINITIONS + ("dimensions#" if h in DIMENSIONS else "metrics#") + h if h in known else None,
|
|
82
|
+
"unit": "count" if h in COUNTS and reporting else FLOATS.get(h) if reporting else None} for h in headers}
|
|
83
|
+
for raw in reader:
|
|
84
|
+
if len(rows) >= 100000 or len(raw) != len(headers) or any(len(v) > 8192 for v in raw):
|
|
85
|
+
raise ResourceError("CSV row/column/cell limit or schema mismatch")
|
|
86
|
+
values = dict(zip(headers, raw))
|
|
87
|
+
# Only vetted Reporting dimensions establish a row's primary key.
|
|
88
|
+
# Unknown semantics (especially Studio titles) cannot justify dedupe;
|
|
89
|
+
# preserve every such row in source order, including identical rows.
|
|
90
|
+
if reporting and set(headers) <= known:
|
|
91
|
+
identity = tuple(values[h] for h in headers if h in DIMENSIONS)
|
|
92
|
+
if identity in seen:
|
|
93
|
+
raise ResourceError("duplicate CSV dimension identity")
|
|
94
|
+
seen.add(identity)
|
|
95
|
+
if reporting:
|
|
96
|
+
raw_date = values["date"]
|
|
97
|
+
if len(raw_date) != 8 or not raw_date.isdigit():
|
|
98
|
+
raise ResourceError("schema: Reporting date requires YYYYMMDD")
|
|
99
|
+
day = date.fromisoformat(raw_date[:4]+"-"+raw_date[4:6]+"-"+raw_date[6:])
|
|
100
|
+
if not start <= day <= end:
|
|
101
|
+
raise ResourceError("CSV date outside bound report")
|
|
102
|
+
if values.get("channel_id") not in {None, "", binding["account_id"]}:
|
|
103
|
+
raise ResourceError("CSV account mismatch")
|
|
104
|
+
row = {"raw": values, "dimensions": {}, "metrics": {}, "unknown": {}}
|
|
105
|
+
for h, value in values.items():
|
|
106
|
+
if columns[h]["kind"] == "dimension":
|
|
107
|
+
row["dimensions"][h] = value
|
|
108
|
+
elif columns[h]["kind"] == "metric":
|
|
109
|
+
number = None if value == "" else int(value) if h in COUNTS else float(value)
|
|
110
|
+
row["metrics"][h] = metric_value(number, "empty_csv_cell_not_zero")
|
|
111
|
+
else:
|
|
112
|
+
row["unknown"][h] = {"raw": value, "missing_reason": "unknown_column_semantics"}
|
|
113
|
+
rows.append(row)
|
|
114
|
+
return {"headers": headers, "columns": columns, "rows": rows}
|
|
115
|
+
except (ValueError, UnicodeError, csv.Error, StopIteration):
|
|
116
|
+
raise ResourceError("CSV schema/encoding/numeric value invalid; no checkpoint committed") from None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ObservationStore(ResourceStore):
|
|
120
|
+
def __init__(self, brand_root, account_id):
|
|
121
|
+
self.root = Path(brand_root) / ".socialctl" / "youtube-observations"
|
|
122
|
+
self.account_id = opaque(account_id)
|
|
123
|
+
|
|
124
|
+
def _read_json(self, path):
|
|
125
|
+
value=json.loads(read_supplied(path, 128*1024*1024))
|
|
126
|
+
provenance=value.get('provenance',{})
|
|
127
|
+
if provenance and provenance.get('source')!='youtubeStudio':
|
|
128
|
+
from socialctl.youtube_data import require_fresh_api_data
|
|
129
|
+
require_fresh_api_data(provenance.get('observed_at'))
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
def index(self):
|
|
133
|
+
self._ensure_root()
|
|
134
|
+
path = self.root / "index.json"
|
|
135
|
+
if not path.exists() and not path.is_symlink():
|
|
136
|
+
return {"version": 1, "account_id": self.account_id, "imports": {}, "active": {}}
|
|
137
|
+
value = self._read_json(path)
|
|
138
|
+
if value.get("version") != 1 or value.get("account_id") != self.account_id:
|
|
139
|
+
raise ResourceError("observation index account/schema mismatch")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
def _commit_index(self, value):
|
|
143
|
+
raw = json.dumps(value, ensure_ascii=False, sort_keys=True).encode()
|
|
144
|
+
if len(raw) > 16*1024*1024:
|
|
145
|
+
raise ResourceError("checkpoint size limit")
|
|
146
|
+
if (self.root / "index.json").is_symlink():
|
|
147
|
+
raise ResourceError("checkpoint symlink rejected")
|
|
148
|
+
fd, temporary = tempfile.mkstemp(dir=self.root, suffix=".tmp")
|
|
149
|
+
try:
|
|
150
|
+
with os.fdopen(fd, "wb") as stream:
|
|
151
|
+
stream.write(raw)
|
|
152
|
+
stream.flush()
|
|
153
|
+
os.fsync(stream.fileno())
|
|
154
|
+
os.replace(temporary, self.root / "index.json")
|
|
155
|
+
self._sync_directory(self.root)
|
|
156
|
+
finally:
|
|
157
|
+
if os.path.exists(temporary):
|
|
158
|
+
os.unlink(temporary)
|
|
159
|
+
|
|
160
|
+
def _immutable_file(self, path, data):
|
|
161
|
+
if path.exists() or path.is_symlink():
|
|
162
|
+
if read_supplied(path, max(len(data), MAX_CSV)) != data:
|
|
163
|
+
raise ResourceError("immutable observation file conflict")
|
|
164
|
+
else:
|
|
165
|
+
save_download(path, data)
|
|
166
|
+
|
|
167
|
+
def import_csv(self, data, binding):
|
|
168
|
+
# Canonicalize supplied metadata before checking ownership or persistence.
|
|
169
|
+
if binding.get("source") == "youtubeStudio":
|
|
170
|
+
binding = StudioMetadata.model_validate(binding).model_dump(mode="json")
|
|
171
|
+
binding = json.loads(json.dumps(binding, allow_nan=False))
|
|
172
|
+
# This observation describes authorization at read time, not the report's
|
|
173
|
+
# immutable identity. A changed grant/expiry must not defeat report dedupe.
|
|
174
|
+
credentials = binding.pop("credential_observation", None)
|
|
175
|
+
if binding.get("account_id") != self.account_id:
|
|
176
|
+
raise ResourceError("import account mismatch")
|
|
177
|
+
source = binding.get("source")
|
|
178
|
+
if source == "youtubeReporting":
|
|
179
|
+
report = binding.get("report", {})
|
|
180
|
+
rid, job, rtype = opaque(report.get("id")), opaque(binding.get("job_id")), opaque(binding.get("report_type_id"))
|
|
181
|
+
if report.get("jobId") != job:
|
|
182
|
+
raise ResourceError("report/job binding mismatch")
|
|
183
|
+
start, end = report_period(report)
|
|
184
|
+
created = instant(report.get("createTime")).isoformat()
|
|
185
|
+
period = [source, self.account_id, job, rtype, start.isoformat(), end.isoformat()]
|
|
186
|
+
identifier = [source, self.account_id, job, rid]
|
|
187
|
+
elif source == "youtubeStudio":
|
|
188
|
+
rid = opaque(binding.get("export_id"))
|
|
189
|
+
start, end = date.fromisoformat(binding["start_date"]), date.fromisoformat(binding["end_date"])
|
|
190
|
+
if start > end:
|
|
191
|
+
raise ResourceError("invalid supplied export dates")
|
|
192
|
+
created = instant(binding.get("exported_at")).isoformat()
|
|
193
|
+
if not isinstance(binding.get("source_filename"), str) or not binding["source_filename"]:
|
|
194
|
+
raise ResourceError("supplied source filename required")
|
|
195
|
+
period = [source, self.account_id, rid, start.isoformat(), end.isoformat()]
|
|
196
|
+
identifier = [source, self.account_id, rid]
|
|
197
|
+
else:
|
|
198
|
+
raise ResourceError("unsupported CSV provenance source")
|
|
199
|
+
parsed = parse_csv(data, binding)
|
|
200
|
+
key = digest(json.dumps(identifier).encode())
|
|
201
|
+
period_key = digest(json.dumps(period).encode())
|
|
202
|
+
checksum = digest(data)
|
|
203
|
+
binding_digest = digest(json.dumps(binding, sort_keys=True).encode())
|
|
204
|
+
with self.apply_lock(str(uuid.uuid5(uuid.NAMESPACE_URL, "youtube-observations-index"))):
|
|
205
|
+
index = self.index()
|
|
206
|
+
existing = index["imports"].get(key)
|
|
207
|
+
if existing:
|
|
208
|
+
if (existing["sha256"], existing["binding_sha256"]) != (checksum, binding_digest):
|
|
209
|
+
raise ResourceError("same report ID has different bytes/binding; refuses overwrite")
|
|
210
|
+
return self._read_json(self.root / (key + ".json"))
|
|
211
|
+
p = provenance(source, self.account_id)
|
|
212
|
+
p.update({"account_binding": "authenticated_report_job_chain" if source == "youtubeReporting" else "user_supplied_not_API_verified",
|
|
213
|
+
"definitions_url": DEFINITIONS + "metrics" if source == "youtubeReporting" else None,
|
|
214
|
+
"dimensions": [h for h in parsed["headers"] if parsed["columns"][h]["kind"] == "dimension"],
|
|
215
|
+
"latency": "first report up to 48h; generated files only" if source == "youtubeReporting" else "user_supplied_export",
|
|
216
|
+
"historical_coverage": "provider generated; normal downloads 60 days, historical 30 days; not promised lifetime" if source == "youtubeReporting" else "declared supplied period"})
|
|
217
|
+
if source == "youtubeReporting":
|
|
218
|
+
p["credential_observation"] = credentials if credentials is not None else credential_metadata({})
|
|
219
|
+
if source == "youtubeStudio":
|
|
220
|
+
p["retention"] = {"category": "user_supplied_export", "api_cleanup_eligible": False,
|
|
221
|
+
"originals": "supplied original files are never changed or removed"}
|
|
222
|
+
observation = {"id": key, "binding": binding, "sha256": checksum, "provenance": p, **parsed}
|
|
223
|
+
serialized = json.dumps(observation, ensure_ascii=False, allow_nan=False).encode()
|
|
224
|
+
if len(serialized) > 128*1024*1024:
|
|
225
|
+
raise ResourceError("parsed observation byte limit; no checkpoint committed")
|
|
226
|
+
# A crash can leave orphan immutable files, but only the final atomic
|
|
227
|
+
# index replacement activates a complete import. Recovery reuses bytes.
|
|
228
|
+
raw_path = self.root / (key + ".csv")
|
|
229
|
+
parsed_path = self.root / (key + ".json")
|
|
230
|
+
self._immutable_file(raw_path, data)
|
|
231
|
+
if parsed_path.exists():
|
|
232
|
+
orphan = self._read_json(parsed_path)
|
|
233
|
+
if orphan.get("sha256") != checksum or orphan.get("binding") != binding:
|
|
234
|
+
raise ResourceError("orphan import binding conflict")
|
|
235
|
+
observation = orphan
|
|
236
|
+
else:
|
|
237
|
+
self._immutable_file(parsed_path, serialized)
|
|
238
|
+
index["imports"][key] = {"sha256": checksum, "binding_sha256": binding_digest, "period_key": period_key, "created_at": created, "checkpoint": "complete"}
|
|
239
|
+
previous = index["active"].get(period_key)
|
|
240
|
+
if previous is None or created > index["imports"][previous]["created_at"]:
|
|
241
|
+
index["active"][period_key] = key
|
|
242
|
+
elif created == index["imports"][previous]["created_at"]:
|
|
243
|
+
raise ResourceError("ambiguous equal-time backfill; no active version guessed")
|
|
244
|
+
self._commit_index(index)
|
|
245
|
+
return observation
|
|
246
|
+
|
|
247
|
+
def save_query(self, result):
|
|
248
|
+
if result.get("provenance", {}).get("account_id") != self.account_id:
|
|
249
|
+
raise ResourceError("query account mismatch")
|
|
250
|
+
self._ensure_root()
|
|
251
|
+
data = json.dumps(result, ensure_ascii=False, allow_nan=False).encode()
|
|
252
|
+
if len(data) > 128*1024*1024:
|
|
253
|
+
raise ResourceError("query observation byte limit")
|
|
254
|
+
key = digest(data)
|
|
255
|
+
self._immutable_file(self.root / (key + ".query.json"), data)
|
|
256
|
+
return {"id": key, "path": str(self.root / (key + ".query.json")), "complete": result["complete"]}
|
|
257
|
+
|
|
258
|
+
def active(self):
|
|
259
|
+
index = self.index()
|
|
260
|
+
return [self._read_json(self.root / (key + ".json")) for key in index["active"].values()]
|