catence 0.0.0 → 0.1.0
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.
- package/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .staging import StravaStagingWriter
|
|
13
|
+
|
|
14
|
+
SCOPES = ["read", "activity:read_all", "read_all"]
|
|
15
|
+
READ_ALLOWLIST = {
|
|
16
|
+
"athlete", "gear", "activity_candidates", "activity_detail", "segment", "segment_efforts",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StravaConnectionError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_args() -> argparse.Namespace:
|
|
25
|
+
parser = argparse.ArgumentParser(description="Stage targeted, read-only Strava data for Catence.")
|
|
26
|
+
parser.add_argument("--mode", required=True, choices=["authorization-url", "auth", "gear", "activity", "segment-history"])
|
|
27
|
+
parser.add_argument("--data-dir", required=True, type=Path)
|
|
28
|
+
parser.add_argument("--output", type=Path)
|
|
29
|
+
parser.add_argument("--result", required=True, type=Path)
|
|
30
|
+
parser.add_argument("--run-id")
|
|
31
|
+
parser.add_argument("--code")
|
|
32
|
+
parser.add_argument("--redirect-uri", default="http://localhost")
|
|
33
|
+
parser.add_argument("--activity-id")
|
|
34
|
+
parser.add_argument("--strava-activity-id")
|
|
35
|
+
parser.add_argument("--strava-match-method")
|
|
36
|
+
parser.add_argument("--started-at")
|
|
37
|
+
parser.add_argument("--sport")
|
|
38
|
+
parser.add_argument("--distance-m", type=float)
|
|
39
|
+
parser.add_argument("--elapsed-s", type=float)
|
|
40
|
+
parser.add_argument("--segment-id")
|
|
41
|
+
parser.add_argument("--start-page", type=int, default=1)
|
|
42
|
+
parser.add_argument("--refresh", action="store_true")
|
|
43
|
+
return parser.parse_args()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def secret_path(data_dir: Path) -> Path:
|
|
47
|
+
return data_dir / "secrets" / "strava.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_secret(data_dir: Path, value: dict[str, Any]) -> None:
|
|
51
|
+
destination = secret_path(data_dir)
|
|
52
|
+
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
53
|
+
temporary = destination.with_suffix(".tmp")
|
|
54
|
+
temporary.write_text(json.dumps(value, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
|
55
|
+
os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
|
|
56
|
+
os.replace(temporary, destination)
|
|
57
|
+
os.chmod(destination, stat.S_IRUSR | stat.S_IWUSR)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_secret(data_dir: Path) -> dict[str, Any]:
|
|
61
|
+
destination = secret_path(data_dir)
|
|
62
|
+
try:
|
|
63
|
+
value = json.loads(destination.read_text(encoding="utf-8"))
|
|
64
|
+
except FileNotFoundError as error:
|
|
65
|
+
raise StravaConnectionError("Strava is not connected. Run `catence-data auth strava` first.") from error
|
|
66
|
+
if not isinstance(value, dict) or not value.get("client_id") or not value.get("client_secret") or not value.get("access_token"):
|
|
67
|
+
raise StravaConnectionError("Strava credentials are incomplete. Run `catence-data auth strava` again.")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def object_value(value: Any) -> dict[str, Any]:
|
|
72
|
+
if isinstance(value, dict):
|
|
73
|
+
return value
|
|
74
|
+
if hasattr(value, "model_dump"):
|
|
75
|
+
dumped = value.model_dump()
|
|
76
|
+
return dumped if isinstance(dumped, dict) else {}
|
|
77
|
+
if hasattr(value, "dict"):
|
|
78
|
+
dumped = value.dict()
|
|
79
|
+
return dumped if isinstance(dumped, dict) else {}
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def timestamp(value: Any) -> float | None:
|
|
84
|
+
if not isinstance(value, str):
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def sport_family(value: Any) -> str | None:
|
|
93
|
+
if not isinstance(value, str):
|
|
94
|
+
return None
|
|
95
|
+
normalized = "".join(character for character in value.lower() if character.isalpha())
|
|
96
|
+
if "ride" in normalized or "cycl" in normalized or "bik" in normalized:
|
|
97
|
+
return "ride"
|
|
98
|
+
if "run" in normalized:
|
|
99
|
+
return "run"
|
|
100
|
+
if "swim" in normalized:
|
|
101
|
+
return "swim"
|
|
102
|
+
if "walk" in normalized:
|
|
103
|
+
return "walk"
|
|
104
|
+
if "strength" in normalized or "weight" in normalized:
|
|
105
|
+
return "strength"
|
|
106
|
+
return normalized or None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_virtual_or_indoor(value: Any) -> bool:
|
|
110
|
+
return isinstance(value, str) and ("virtual" in value.lower() or "indoor" in value.lower())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_not_found(error: Exception) -> bool:
|
|
114
|
+
message = str(error).lower()
|
|
115
|
+
return "404" in message or "not found" in message or "record not found" in message
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def matches_activity(candidate: dict[str, Any], started_at: str, sport: str | None, distance_m: float | None, elapsed_s: float | None) -> tuple[bool, dict[str, Any]]:
|
|
119
|
+
candidate_start = timestamp(candidate.get("start_date"))
|
|
120
|
+
requested_start = timestamp(started_at)
|
|
121
|
+
candidate_distance = candidate.get("distance")
|
|
122
|
+
candidate_elapsed = candidate.get("elapsed_time")
|
|
123
|
+
requested_sport = sport_family(sport)
|
|
124
|
+
candidate_sport = sport_family(candidate.get("sport_type") or candidate.get("type"))
|
|
125
|
+
evidence: dict[str, Any] = {
|
|
126
|
+
"requestedSportFamily": requested_sport,
|
|
127
|
+
"candidateSportFamily": candidate_sport,
|
|
128
|
+
"candidateVirtualOrIndoor": is_virtual_or_indoor(candidate.get("sport_type") or candidate.get("type")),
|
|
129
|
+
"rejectionReasons": [],
|
|
130
|
+
}
|
|
131
|
+
if requested_start is None or candidate_start is None or not isinstance(candidate_distance, (int, float)) or not isinstance(candidate_elapsed, (int, float)):
|
|
132
|
+
evidence["rejectionReasons"] = ["candidate_missing_required_fields"]
|
|
133
|
+
return False, evidence
|
|
134
|
+
if distance_m is None or elapsed_s is None:
|
|
135
|
+
evidence["rejectionReasons"] = ["catence_activity_missing_required_fields"]
|
|
136
|
+
return False, evidence
|
|
137
|
+
start_delta = abs(requested_start - candidate_start)
|
|
138
|
+
duration_delta = abs(elapsed_s - float(candidate_elapsed))
|
|
139
|
+
distance_delta = abs(distance_m - float(candidate_distance))
|
|
140
|
+
duration_limit = max(120.0, elapsed_s * 0.05)
|
|
141
|
+
distance_limit = max(200.0, distance_m * 0.025)
|
|
142
|
+
evidence.update({
|
|
143
|
+
"startDeltaSeconds": start_delta, "elapsedDurationDeltaSeconds": duration_delta,
|
|
144
|
+
"distanceDeltaMeters": distance_delta, "elapsedDurationLimitSeconds": duration_limit,
|
|
145
|
+
"distanceLimitMeters": distance_limit,
|
|
146
|
+
})
|
|
147
|
+
reasons: list[str] = []
|
|
148
|
+
if candidate_sport != requested_sport:
|
|
149
|
+
reasons.append("sport_family_mismatch")
|
|
150
|
+
if start_delta > 90:
|
|
151
|
+
reasons.append("start_time_outside_90_second_window")
|
|
152
|
+
if duration_delta > duration_limit:
|
|
153
|
+
reasons.append("elapsed_duration_outside_tolerance")
|
|
154
|
+
if distance_delta > distance_limit:
|
|
155
|
+
reasons.append("distance_outside_tolerance")
|
|
156
|
+
evidence["rejectionReasons"] = reasons
|
|
157
|
+
return not reasons, evidence
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class StravaReader:
|
|
161
|
+
"""A raw GET-only adapter around stravalib's OAuth/session support."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, data_dir: Path):
|
|
164
|
+
try:
|
|
165
|
+
from stravalib.client import Client
|
|
166
|
+
except ImportError as error:
|
|
167
|
+
raise StravaConnectionError("stravalib is unavailable. Install the Catence Python dependencies before connecting Strava.") from error
|
|
168
|
+
self.data_dir = data_dir
|
|
169
|
+
self.secret = read_secret(data_dir)
|
|
170
|
+
self.client = Client(access_token=str(self.secret["access_token"]))
|
|
171
|
+
self.observed_headers: dict[str, str] = {}
|
|
172
|
+
self._delegate_rate_limiter = self.client.protocol.rate_limiter
|
|
173
|
+
self.client.protocol.rate_limiter = self._capture_rate_headers
|
|
174
|
+
self._refresh_if_needed()
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def authorization_url(client_id: str, redirect_uri: str) -> str:
|
|
178
|
+
from stravalib.client import Client
|
|
179
|
+
return Client().authorization_url(client_id=client_id, redirect_uri=redirect_uri, scope=SCOPES)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def exchange_code(data_dir: Path, code: str, redirect_uri: str, client_id: str, client_secret: str) -> dict[str, Any]:
|
|
183
|
+
from stravalib.client import Client
|
|
184
|
+
client = Client()
|
|
185
|
+
token = object_value(client.exchange_code_for_token(client_id=client_id, client_secret=client_secret, code=code))
|
|
186
|
+
token["client_id"] = client_id
|
|
187
|
+
token["client_secret"] = client_secret
|
|
188
|
+
token["redirect_uri"] = redirect_uri
|
|
189
|
+
token["scopes"] = SCOPES
|
|
190
|
+
if not token.get("access_token"):
|
|
191
|
+
raise StravaConnectionError("Strava did not return an access token. Check the authorization code and requested scopes.")
|
|
192
|
+
write_secret(data_dir, token)
|
|
193
|
+
return token
|
|
194
|
+
|
|
195
|
+
def _refresh_if_needed(self) -> None:
|
|
196
|
+
expires_at = self.secret.get("expires_at")
|
|
197
|
+
if not isinstance(expires_at, (int, float)) or expires_at > datetime.now(timezone.utc).timestamp() + 60:
|
|
198
|
+
return
|
|
199
|
+
refreshed = object_value(self.client.refresh_access_token(
|
|
200
|
+
client_id=str(self.secret["client_id"]), client_secret=str(self.secret["client_secret"]),
|
|
201
|
+
refresh_token=str(self.secret.get("refresh_token") or ""),
|
|
202
|
+
))
|
|
203
|
+
if not refreshed.get("access_token"):
|
|
204
|
+
raise StravaConnectionError("Strava token refresh failed. Reconnect with `catence-data auth strava`.")
|
|
205
|
+
self.secret.update(refreshed)
|
|
206
|
+
write_secret(self.data_dir, self.secret)
|
|
207
|
+
self.client.access_token = str(self.secret["access_token"])
|
|
208
|
+
|
|
209
|
+
def _capture_rate_headers(self, headers: dict[str, str], method: Any) -> None:
|
|
210
|
+
"""Record Strava's rate-limit headers without disabling stravalib's limiter.
|
|
211
|
+
|
|
212
|
+
``ApiV3`` calls its rate limiter after every non-OAuth response with
|
|
213
|
+
``(response.headers, request_method)``. Keep the header names stable
|
|
214
|
+
for the TypeScript worker result while delegating to the limiter that
|
|
215
|
+
stravalib installed on the client.
|
|
216
|
+
"""
|
|
217
|
+
for name, value in headers.items():
|
|
218
|
+
normalized = str(name).lower()
|
|
219
|
+
if normalized in {"x-ratelimit-limit", "x-ratelimit-usage"}:
|
|
220
|
+
self.observed_headers[normalized] = str(value)
|
|
221
|
+
self._delegate_rate_limiter(headers, method)
|
|
222
|
+
|
|
223
|
+
def get(self, endpoint: str, path: str, **params: Any) -> Any:
|
|
224
|
+
if endpoint not in READ_ALLOWLIST:
|
|
225
|
+
raise RuntimeError(f"Strava endpoint {endpoint!r} is not in Catence's GET allowlist.")
|
|
226
|
+
try:
|
|
227
|
+
return self.client.protocol.get(path, **params)
|
|
228
|
+
except Exception as error:
|
|
229
|
+
if "rate" in type(error).__name__.lower() or "429" in str(error):
|
|
230
|
+
raise StravaConnectionError("Strava rate limited this request (HTTP 429).") from error
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def result_path_write(path: Path, result: dict[str, Any]) -> None:
|
|
235
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
path.write_text(json.dumps(result, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def stage_gear(reader: StravaReader, writer: StravaStagingWriter) -> dict[str, Any]:
|
|
240
|
+
athlete = object_value(reader.get("athlete", "/athlete"))
|
|
241
|
+
athlete_hash = writer.archive_json("athlete", str(athlete.get("id") or "athlete"), athlete)
|
|
242
|
+
writer.source_entity("athlete", str(athlete.get("id") or "athlete"), athlete, athlete_hash)
|
|
243
|
+
gear_ids = [str(item.get("id")) for key in ("bikes", "shoes") for item in athlete.get(key, []) if isinstance(item, dict) and item.get("id")]
|
|
244
|
+
for gear_id in gear_ids:
|
|
245
|
+
gear = object_value(reader.get("gear", f"/gear/{gear_id}"))
|
|
246
|
+
raw_hash = writer.archive_json("gear", gear_id, gear)
|
|
247
|
+
writer.source_entity("gear", gear_id, gear, raw_hash)
|
|
248
|
+
return {"status": "completed", "gearCount": len(gear_ids), "rateHeaders": reader.observed_headers}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def stage_activity(reader: StravaReader, writer: StravaStagingWriter, args: argparse.Namespace) -> dict[str, Any]:
|
|
252
|
+
direct_lookup_unavailable = False
|
|
253
|
+
if args.strava_activity_id:
|
|
254
|
+
remote_id = str(args.strava_activity_id)
|
|
255
|
+
match_method = args.strava_match_method or "strong_strava_id"
|
|
256
|
+
try:
|
|
257
|
+
detail = object_value(reader.get("activity_detail", f"/activities/{remote_id}", include_all_efforts="true"))
|
|
258
|
+
if str(detail.get("id")) != remote_id:
|
|
259
|
+
raise StravaConnectionError("Strava returned an activity whose ID did not match Catence's provider-supplied Strava ID.")
|
|
260
|
+
detail_hash = writer.archive_json("activity_detail", remote_id, detail, {"activitySourceId": args.activity_id, "includeAllEfforts": True, "matchMethod": match_method})
|
|
261
|
+
writer.source_entity("activity", remote_id, detail, detail_hash)
|
|
262
|
+
gear_id = detail.get("gear_id")
|
|
263
|
+
if isinstance(gear_id, (str, int)) and str(gear_id):
|
|
264
|
+
gear = object_value(reader.get("gear", f"/gear/{gear_id}"))
|
|
265
|
+
gear_hash = writer.archive_json("gear", str(gear_id), gear, {"activityId": remote_id})
|
|
266
|
+
writer.source_entity("gear", str(gear_id), gear, gear_hash)
|
|
267
|
+
return {"status": "completed", "stravaActivityId": remote_id, "rawHash": detail_hash, "matchEvidence": {"method": match_method, "stravaActivityId": remote_id}, "rateHeaders": reader.observed_headers}
|
|
268
|
+
except Exception as error:
|
|
269
|
+
if match_method == "source_strava_id" and is_not_found(error):
|
|
270
|
+
return {
|
|
271
|
+
"status": "not_found", "stravaActivityId": remote_id,
|
|
272
|
+
"message": "The source's archived Strava activity ID is not available to the authenticated Strava account.",
|
|
273
|
+
"matchEvidence": {"method": match_method, "stravaActivityId": remote_id},
|
|
274
|
+
"matchDiagnostics": {
|
|
275
|
+
"strategy": "direct_strava_activity_id",
|
|
276
|
+
"likelyReasons": ["The activity is unavailable to the authenticated Strava account.", "Confirm the connected athlete and that the activity has not been deleted or made inaccessible."],
|
|
277
|
+
},
|
|
278
|
+
"rateHeaders": reader.observed_headers,
|
|
279
|
+
}
|
|
280
|
+
if not is_not_found(error):
|
|
281
|
+
raise
|
|
282
|
+
direct_lookup_unavailable = True
|
|
283
|
+
if not args.started_at or not args.sport or args.distance_m is None or args.elapsed_s is None:
|
|
284
|
+
raise StravaConnectionError("Activity hydration requires a start time, sport, distance, and elapsed duration from the Catence activity.")
|
|
285
|
+
start = timestamp(args.started_at)
|
|
286
|
+
if start is None:
|
|
287
|
+
raise StravaConnectionError("The Catence activity has an invalid start time and cannot be matched to Strava.")
|
|
288
|
+
candidates = reader.get("activity_candidates", "/athlete/activities", after=int(start - 90), before=int(start + 90), page=1, per_page=200)
|
|
289
|
+
if not isinstance(candidates, list):
|
|
290
|
+
candidates = []
|
|
291
|
+
candidates_hash = writer.archive_json("activity_candidates", args.activity_id, candidates, {"activitySourceId": args.activity_id})
|
|
292
|
+
qualified: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
|
293
|
+
rejection_counts: dict[str, int] = {}
|
|
294
|
+
for candidate in candidates:
|
|
295
|
+
if not isinstance(candidate, dict):
|
|
296
|
+
continue
|
|
297
|
+
accepted, evidence = matches_activity(candidate, args.started_at, args.sport, args.distance_m, args.elapsed_s)
|
|
298
|
+
if accepted:
|
|
299
|
+
qualified.append((candidate, evidence))
|
|
300
|
+
else:
|
|
301
|
+
for reason in evidence.get("rejectionReasons", []):
|
|
302
|
+
rejection_counts[reason] = rejection_counts.get(reason, 0) + 1
|
|
303
|
+
if len(qualified) != 1:
|
|
304
|
+
search_start = datetime.fromtimestamp(start - 90, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
305
|
+
search_end = datetime.fromtimestamp(start + 90, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
306
|
+
if not candidates:
|
|
307
|
+
likely_reasons = [
|
|
308
|
+
"Strava returned no activities in the exact ±90-second search window.",
|
|
309
|
+
"Confirm that Catence is connected to the same Strava athlete and that activity:read_all access includes the activity.",
|
|
310
|
+
"A private, deleted, or differently timestamped Strava activity cannot be matched from this result.",
|
|
311
|
+
]
|
|
312
|
+
elif not qualified:
|
|
313
|
+
likely_reasons = ["Strava returned activities, but none met Catence's safe sport, time, elapsed-duration, and distance tolerances."]
|
|
314
|
+
else:
|
|
315
|
+
likely_reasons = ["More than one Strava activity met the safe matching tolerances; Catence will not choose one automatically."]
|
|
316
|
+
return {
|
|
317
|
+
"status": "not_found" if not qualified else "ambiguous", "candidateCount": len(qualified),
|
|
318
|
+
"candidatesRawHash": candidates_hash,
|
|
319
|
+
"matchDiagnostics": {
|
|
320
|
+
"strategy": "time_sport_distance",
|
|
321
|
+
"searchWindow": {"startAt": search_start, "endAt": search_end},
|
|
322
|
+
"returnedCandidateCount": len(candidates), "qualifiedCandidateCount": len(qualified),
|
|
323
|
+
"rejectionCounts": rejection_counts, "directLookupUnavailable": direct_lookup_unavailable,
|
|
324
|
+
"likelyReasons": likely_reasons,
|
|
325
|
+
},
|
|
326
|
+
"rateHeaders": reader.observed_headers,
|
|
327
|
+
}
|
|
328
|
+
selected, evidence = qualified[0]
|
|
329
|
+
remote_id = str(selected["id"])
|
|
330
|
+
detail = object_value(reader.get("activity_detail", f"/activities/{remote_id}", include_all_efforts="true"))
|
|
331
|
+
detail_hash = writer.archive_json("activity_detail", remote_id, detail, {"activitySourceId": args.activity_id, "includeAllEfforts": True})
|
|
332
|
+
writer.source_entity("activity", remote_id, detail, detail_hash, occurred_on=str(detail.get("start_date") or "")[:10] or None)
|
|
333
|
+
gear_id = detail.get("gear_id")
|
|
334
|
+
if isinstance(gear_id, (str, int)) and str(gear_id):
|
|
335
|
+
gear = object_value(reader.get("gear", f"/gear/{gear_id}"))
|
|
336
|
+
gear_hash = writer.archive_json("gear", str(gear_id), gear, {"activityId": remote_id})
|
|
337
|
+
writer.source_entity("gear", str(gear_id), gear, gear_hash)
|
|
338
|
+
return {"status": "completed", "stravaActivityId": remote_id, "rawHash": detail_hash, "matchEvidence": evidence, "rateHeaders": reader.observed_headers}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def stage_segment_history(reader: StravaReader, writer: StravaStagingWriter, args: argparse.Namespace) -> dict[str, Any]:
|
|
342
|
+
if not args.segment_id:
|
|
343
|
+
raise StravaConnectionError("Segment history hydration requires a persisted Strava segment ID.")
|
|
344
|
+
segment = object_value(reader.get("segment", f"/segments/{args.segment_id}"))
|
|
345
|
+
segment_hash = writer.archive_json("segment", args.segment_id, segment)
|
|
346
|
+
writer.source_entity("segment", args.segment_id, segment, segment_hash)
|
|
347
|
+
page = max(1, args.start_page)
|
|
348
|
+
effort_count = 0
|
|
349
|
+
while True:
|
|
350
|
+
try:
|
|
351
|
+
efforts = reader.get("segment_efforts", "/segment_efforts", segment_id=args.segment_id, page=page, per_page=200)
|
|
352
|
+
except StravaConnectionError as error:
|
|
353
|
+
if "rate limited" in str(error).lower():
|
|
354
|
+
return {"status": "partial", "segmentId": args.segment_id, "effortCount": effort_count, "continuationPage": page, "message": str(error), "rateHeaders": reader.observed_headers}
|
|
355
|
+
raise
|
|
356
|
+
if not isinstance(efforts, list):
|
|
357
|
+
efforts = []
|
|
358
|
+
raw_hash = writer.archive_json("segment_efforts", args.segment_id, efforts, {"segmentId": args.segment_id, "page": page})
|
|
359
|
+
for effort in efforts:
|
|
360
|
+
if not isinstance(effort, dict) or not effort.get("id"):
|
|
361
|
+
continue
|
|
362
|
+
effort.setdefault("segment", segment)
|
|
363
|
+
writer.source_entity("segment_effort", str(effort["id"]), effort, raw_hash, parent_remote_id=args.segment_id, occurred_on=str(effort.get("start_date") or "")[:10] or None)
|
|
364
|
+
effort_count += 1
|
|
365
|
+
if len(efforts) < 200:
|
|
366
|
+
return {"status": "completed", "segmentId": args.segment_id, "effortCount": effort_count, "lastCompletedPage": page, "rateHeaders": reader.observed_headers}
|
|
367
|
+
page += 1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main() -> None:
|
|
371
|
+
args = parse_args()
|
|
372
|
+
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
|
373
|
+
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
|
374
|
+
try:
|
|
375
|
+
if args.mode == "authorization-url":
|
|
376
|
+
if not client_id:
|
|
377
|
+
raise StravaConnectionError("STRAVA_CLIENT_ID is required to begin Strava authorization.")
|
|
378
|
+
result_path_write(args.result, {"status": "authorization_required", "authorizationUrl": StravaReader.authorization_url(client_id, args.redirect_uri), "scopes": SCOPES})
|
|
379
|
+
return
|
|
380
|
+
if args.mode == "auth":
|
|
381
|
+
if not args.code or not client_id or not client_secret:
|
|
382
|
+
raise StravaConnectionError("STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and --code are required to complete Strava authorization.")
|
|
383
|
+
token = StravaReader.exchange_code(args.data_dir, args.code, args.redirect_uri, client_id, client_secret)
|
|
384
|
+
result_path_write(args.result, {"status": "connected", "athleteId": object_value(token.get("athlete")).get("id"), "scopes": SCOPES})
|
|
385
|
+
return
|
|
386
|
+
if not args.output or not args.run_id:
|
|
387
|
+
raise StravaConnectionError("Strava read operations require --output and --run-id.")
|
|
388
|
+
writer = StravaStagingWriter(args.data_dir, args.output)
|
|
389
|
+
writer.manifest(args.run_id, datetime.now(timezone.utc).date().isoformat())
|
|
390
|
+
reader = StravaReader(args.data_dir)
|
|
391
|
+
if args.mode == "gear":
|
|
392
|
+
result = stage_gear(reader, writer)
|
|
393
|
+
elif args.mode == "activity":
|
|
394
|
+
result = stage_activity(reader, writer, args)
|
|
395
|
+
else:
|
|
396
|
+
result = stage_segment_history(reader, writer, args)
|
|
397
|
+
result_path_write(args.result, result)
|
|
398
|
+
except Exception as error:
|
|
399
|
+
result_path_write(args.result, {"status": "partial" if "rate limited" in str(error).lower() else "error", "message": str(error)})
|
|
400
|
+
raise SystemExit(str(error))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
if __name__ == "__main__":
|
|
404
|
+
main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def utc_now() -> str:
|
|
13
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def safe_segment(value: str) -> str:
|
|
17
|
+
return "".join(character if character.isalnum() or character in "._-" else "_" for character in value)[:160] or "unknown"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class StravaStagingWriter:
|
|
22
|
+
"""Archives the exact HTTP payload before emitting its normalizable envelope."""
|
|
23
|
+
|
|
24
|
+
data_dir: Path
|
|
25
|
+
output: Path
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
self.output.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self.output.touch(exist_ok=True)
|
|
30
|
+
|
|
31
|
+
def emit(self, record: dict[str, Any]) -> None:
|
|
32
|
+
record["schemaVersion"] = 1
|
|
33
|
+
record["provider"] = "strava"
|
|
34
|
+
with self.output.open("a", encoding="utf-8") as stream:
|
|
35
|
+
stream.write(json.dumps(record, separators=(",", ":"), default=str))
|
|
36
|
+
stream.write("\n")
|
|
37
|
+
|
|
38
|
+
def manifest(self, run_id: str, from_date: str) -> None:
|
|
39
|
+
self.emit({"kind": "run_manifest", "runId": run_id, "fromDate": from_date, "createdAt": utc_now()})
|
|
40
|
+
|
|
41
|
+
def archive_json(self, endpoint: str, remote_id: str | None, payload: Any, scope: dict[str, Any] | None = None) -> str:
|
|
42
|
+
contents = (json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n").encode("utf-8")
|
|
43
|
+
digest = hashlib.sha256(contents).hexdigest()
|
|
44
|
+
relative = Path("raw") / "strava" / safe_segment(endpoint) / safe_segment(remote_id or "collection") / f"{digest}.json"
|
|
45
|
+
destination = self.data_dir / relative
|
|
46
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
if not destination.exists():
|
|
48
|
+
temporary = destination.with_suffix(".json.tmp")
|
|
49
|
+
temporary.write_bytes(contents)
|
|
50
|
+
os.replace(temporary, destination)
|
|
51
|
+
self.emit({
|
|
52
|
+
"kind": "raw_object", "endpoint": endpoint, "remoteId": remote_id,
|
|
53
|
+
"fetchedAt": utc_now(), "contentHash": digest, "contentType": "application/json",
|
|
54
|
+
"relativePath": str(relative), "scope": scope or {},
|
|
55
|
+
})
|
|
56
|
+
return digest
|
|
57
|
+
|
|
58
|
+
def source_entity(
|
|
59
|
+
self, entity_type: str, remote_id: str, payload: dict[str, Any], raw_hash: str | None,
|
|
60
|
+
parent_remote_id: str | None = None, occurred_on: str | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.emit({
|
|
63
|
+
"kind": "source_entity", "entityType": entity_type, "remoteId": str(remote_id),
|
|
64
|
+
"parentRemoteId": parent_remote_id, "occurredOn": occurred_on,
|
|
65
|
+
"sourceUpdatedAt": payload.get("updated_at") if isinstance(payload.get("updated_at"), str) else None,
|
|
66
|
+
"rawObjectHash": raw_hash, "payload": payload, "extension": {},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
def error(self, endpoint: str, message: str, remote_id: str | None = None, retryable: bool = True) -> None:
|
|
70
|
+
self.emit({
|
|
71
|
+
"kind": "extraction_error", "endpoint": endpoint, "remoteId": remote_id,
|
|
72
|
+
"message": message, "retryable": retryable,
|
|
73
|
+
})
|