devrel-origin 0.2.14__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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostHog API v2 async client (legacy — retained for interface compatibility).
|
|
3
|
+
|
|
4
|
+
Originally a typed, retryable wrapper around PostHog's REST API.
|
|
5
|
+
OpenClaw does not have an equivalent external API, so this module is kept
|
|
6
|
+
as a structural dependency for agent imports but is not used for live API calls.
|
|
7
|
+
The PostHogClient class and its DTOs remain functional for testing and
|
|
8
|
+
reference purposes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Configuration
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
DEFAULT_HOST = "https://app.posthog.com"
|
|
24
|
+
API_TIMEOUT = 30.0
|
|
25
|
+
MAX_RETRIES = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Data transfer objects
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class InsightQuery:
|
|
35
|
+
"""Parameters for a PostHog insight query."""
|
|
36
|
+
|
|
37
|
+
insight: str = "TRENDS" # TRENDS, FUNNELS, RETENTION, PATHS, LIFECYCLE
|
|
38
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
39
|
+
properties: list[dict[str, Any]] = field(default_factory=list)
|
|
40
|
+
date_from: str = "-7d"
|
|
41
|
+
date_to: Optional[str] = None
|
|
42
|
+
interval: str = "day"
|
|
43
|
+
breakdown: Optional[str] = None
|
|
44
|
+
breakdown_type: Optional[str] = None
|
|
45
|
+
filter_test_accounts: bool = True
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
d: dict[str, Any] = {
|
|
49
|
+
"insight": self.insight,
|
|
50
|
+
"events": self.events,
|
|
51
|
+
"properties": self.properties,
|
|
52
|
+
"date_from": self.date_from,
|
|
53
|
+
"interval": self.interval,
|
|
54
|
+
"filter_test_accounts": self.filter_test_accounts,
|
|
55
|
+
}
|
|
56
|
+
if self.date_to:
|
|
57
|
+
d["date_to"] = self.date_to
|
|
58
|
+
if self.breakdown:
|
|
59
|
+
d["breakdown"] = self.breakdown
|
|
60
|
+
d["breakdown_type"] = self.breakdown_type or "event"
|
|
61
|
+
return d
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FeatureFlag:
|
|
66
|
+
"""PostHog feature flag representation."""
|
|
67
|
+
|
|
68
|
+
key: str
|
|
69
|
+
name: str = ""
|
|
70
|
+
active: bool = True
|
|
71
|
+
rollout_percentage: Optional[int] = None
|
|
72
|
+
filters: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
ensure_experience_continuity: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Experiment:
|
|
78
|
+
"""PostHog experiment representation."""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
feature_flag_key: str
|
|
82
|
+
description: str = ""
|
|
83
|
+
start_date: Optional[str] = None
|
|
84
|
+
end_date: Optional[str] = None
|
|
85
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Client
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PostHogClient:
|
|
94
|
+
"""
|
|
95
|
+
Typed async client for the PostHog REST API v2.
|
|
96
|
+
|
|
97
|
+
Usage::
|
|
98
|
+
|
|
99
|
+
client = PostHogClient(api_key="phx_...", project_id="12345")
|
|
100
|
+
trends = await client.query_insights(
|
|
101
|
+
InsightQuery(events=[{"id": "$pageview"}])
|
|
102
|
+
)
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
api_key: str,
|
|
108
|
+
project_id: str = "",
|
|
109
|
+
host: str = DEFAULT_HOST,
|
|
110
|
+
):
|
|
111
|
+
self.api_key = api_key
|
|
112
|
+
self.project_id = project_id
|
|
113
|
+
self.host = host.rstrip("/")
|
|
114
|
+
self._client = httpx.AsyncClient(
|
|
115
|
+
base_url=self.host,
|
|
116
|
+
headers={
|
|
117
|
+
"Authorization": f"Bearer {api_key}",
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
},
|
|
120
|
+
timeout=API_TIMEOUT,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
await self._client.aclose()
|
|
125
|
+
|
|
126
|
+
# -- helpers ----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _url(self, path: str) -> str:
|
|
129
|
+
"""Build a project-scoped API URL."""
|
|
130
|
+
if self.project_id:
|
|
131
|
+
return f"/api/projects/{self.project_id}{path}"
|
|
132
|
+
return f"/api{path}"
|
|
133
|
+
|
|
134
|
+
async def _request(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
path: str,
|
|
138
|
+
*,
|
|
139
|
+
json: Optional[dict[str, Any]] = None,
|
|
140
|
+
params: Optional[dict[str, Any]] = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Execute an HTTP request with retry logic."""
|
|
143
|
+
url = self._url(path)
|
|
144
|
+
last_error: Optional[Exception] = None
|
|
145
|
+
|
|
146
|
+
for attempt in range(1, MAX_RETRIES + 2):
|
|
147
|
+
try:
|
|
148
|
+
resp = await self._client.request(method, url, json=json, params=params)
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
return resp.json()
|
|
151
|
+
except (httpx.HTTPStatusError, httpx.RequestError) as exc:
|
|
152
|
+
last_error = exc
|
|
153
|
+
logger.warning(f"PostHog API {method} {url} failed (attempt {attempt}): {exc}")
|
|
154
|
+
if attempt <= MAX_RETRIES:
|
|
155
|
+
import asyncio
|
|
156
|
+
|
|
157
|
+
await asyncio.sleep(1.0 * attempt)
|
|
158
|
+
|
|
159
|
+
raise last_error # type: ignore[misc]
|
|
160
|
+
|
|
161
|
+
# -- Event Capture ----------------------------------------------------
|
|
162
|
+
|
|
163
|
+
async def capture(
|
|
164
|
+
self,
|
|
165
|
+
distinct_id: str,
|
|
166
|
+
event: str,
|
|
167
|
+
properties: Optional[dict[str, Any]] = None,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""Capture a single event."""
|
|
170
|
+
return await self._request(
|
|
171
|
+
"POST",
|
|
172
|
+
"/capture/",
|
|
173
|
+
json={
|
|
174
|
+
"api_key": self.api_key,
|
|
175
|
+
"distinct_id": distinct_id,
|
|
176
|
+
"event": event,
|
|
177
|
+
"properties": properties or {},
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def capture_batch(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
182
|
+
"""Capture a batch of events."""
|
|
183
|
+
return await self._request(
|
|
184
|
+
"POST",
|
|
185
|
+
"/capture/",
|
|
186
|
+
json={"api_key": self.api_key, "batch": events},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# -- Insights / Queries -----------------------------------------------
|
|
190
|
+
|
|
191
|
+
async def query_insights(self, query: InsightQuery) -> dict[str, Any]:
|
|
192
|
+
"""Run an insight query (trends, funnels, retention, etc.)."""
|
|
193
|
+
return await self._request("POST", "/insights/", json=query.to_dict())
|
|
194
|
+
|
|
195
|
+
async def get_insight(self, insight_id: int) -> dict[str, Any]:
|
|
196
|
+
"""Fetch a saved insight by ID."""
|
|
197
|
+
return await self._request("GET", f"/insights/{insight_id}/")
|
|
198
|
+
|
|
199
|
+
async def list_insights(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
|
|
200
|
+
"""List saved insights with pagination."""
|
|
201
|
+
return await self._request(
|
|
202
|
+
"GET",
|
|
203
|
+
"/insights/",
|
|
204
|
+
params={"limit": limit, "offset": offset},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# -- Feature Flags ----------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async def create_feature_flag(self, flag: FeatureFlag) -> dict[str, Any]:
|
|
210
|
+
"""Create a new feature flag."""
|
|
211
|
+
payload: dict[str, Any] = {
|
|
212
|
+
"key": flag.key,
|
|
213
|
+
"name": flag.name,
|
|
214
|
+
"active": flag.active,
|
|
215
|
+
"filters": flag.filters,
|
|
216
|
+
"ensure_experience_continuity": flag.ensure_experience_continuity,
|
|
217
|
+
}
|
|
218
|
+
if flag.rollout_percentage is not None:
|
|
219
|
+
payload["rollout_percentage"] = flag.rollout_percentage
|
|
220
|
+
return await self._request("POST", "/feature_flags/", json=payload)
|
|
221
|
+
|
|
222
|
+
async def get_feature_flag(self, flag_id: int) -> dict[str, Any]:
|
|
223
|
+
"""Fetch a feature flag by ID."""
|
|
224
|
+
return await self._request("GET", f"/feature_flags/{flag_id}/")
|
|
225
|
+
|
|
226
|
+
async def list_feature_flags(self, limit: int = 100) -> dict[str, Any]:
|
|
227
|
+
"""List all feature flags."""
|
|
228
|
+
return await self._request("GET", "/feature_flags/", params={"limit": limit})
|
|
229
|
+
|
|
230
|
+
async def update_feature_flag(self, flag_id: int, updates: dict[str, Any]) -> dict[str, Any]:
|
|
231
|
+
"""Patch a feature flag."""
|
|
232
|
+
return await self._request("PATCH", f"/feature_flags/{flag_id}/", json=updates)
|
|
233
|
+
|
|
234
|
+
async def delete_feature_flag(self, flag_id: int) -> dict[str, Any]:
|
|
235
|
+
"""Delete a feature flag."""
|
|
236
|
+
return await self._request("DELETE", f"/feature_flags/{flag_id}/")
|
|
237
|
+
|
|
238
|
+
# -- Experiments ------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
async def create_experiment(self, experiment: Experiment) -> dict[str, Any]:
|
|
241
|
+
"""Create a new experiment."""
|
|
242
|
+
return await self._request(
|
|
243
|
+
"POST",
|
|
244
|
+
"/experiments/",
|
|
245
|
+
json={
|
|
246
|
+
"name": experiment.name,
|
|
247
|
+
"feature_flag_key": experiment.feature_flag_key,
|
|
248
|
+
"description": experiment.description,
|
|
249
|
+
"start_date": experiment.start_date,
|
|
250
|
+
"end_date": experiment.end_date,
|
|
251
|
+
"parameters": experiment.parameters,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
async def get_experiment(self, experiment_id: int) -> dict[str, Any]:
|
|
256
|
+
"""Fetch an experiment by ID."""
|
|
257
|
+
return await self._request("GET", f"/experiments/{experiment_id}/")
|
|
258
|
+
|
|
259
|
+
async def list_experiments(self, limit: int = 100) -> dict[str, Any]:
|
|
260
|
+
"""List all experiments."""
|
|
261
|
+
return await self._request("GET", "/experiments/", params={"limit": limit})
|
|
262
|
+
|
|
263
|
+
async def get_experiment_results(self, experiment_id: int) -> dict[str, Any]:
|
|
264
|
+
"""Fetch experiment results with statistical analysis."""
|
|
265
|
+
return await self._request("GET", f"/experiments/{experiment_id}/results/")
|
|
266
|
+
|
|
267
|
+
# -- Cohorts ----------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async def create_cohort(
|
|
270
|
+
self,
|
|
271
|
+
name: str,
|
|
272
|
+
groups: list[dict[str, Any]],
|
|
273
|
+
is_static: bool = False,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Create a new cohort."""
|
|
276
|
+
return await self._request(
|
|
277
|
+
"POST",
|
|
278
|
+
"/cohorts/",
|
|
279
|
+
json={
|
|
280
|
+
"name": name,
|
|
281
|
+
"groups": groups,
|
|
282
|
+
"is_static": is_static,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def get_cohort(self, cohort_id: int) -> dict[str, Any]:
|
|
287
|
+
"""Fetch a cohort by ID."""
|
|
288
|
+
return await self._request("GET", f"/cohorts/{cohort_id}/")
|
|
289
|
+
|
|
290
|
+
async def list_cohorts(self, limit: int = 100) -> dict[str, Any]:
|
|
291
|
+
"""List all cohorts."""
|
|
292
|
+
return await self._request("GET", "/cohorts/", params={"limit": limit})
|
|
293
|
+
|
|
294
|
+
# -- Annotations ------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
async def create_annotation(
|
|
297
|
+
self,
|
|
298
|
+
content: str,
|
|
299
|
+
date_marker: str,
|
|
300
|
+
scope: str = "organization",
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""Create a date annotation (e.g., deploy marker)."""
|
|
303
|
+
return await self._request(
|
|
304
|
+
"POST",
|
|
305
|
+
"/annotations/",
|
|
306
|
+
json={
|
|
307
|
+
"content": content,
|
|
308
|
+
"date_marker": date_marker,
|
|
309
|
+
"scope": scope,
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def list_annotations(self, limit: int = 100) -> dict[str, Any]:
|
|
314
|
+
"""List all annotations."""
|
|
315
|
+
return await self._request("GET", "/annotations/", params={"limit": limit})
|
|
316
|
+
|
|
317
|
+
# -- Persons ----------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
async def get_person(self, distinct_id: str) -> dict[str, Any]:
|
|
320
|
+
"""Look up a person by distinct_id."""
|
|
321
|
+
result = await self._request(
|
|
322
|
+
"GET",
|
|
323
|
+
"/persons/",
|
|
324
|
+
params={"distinct_id": distinct_id},
|
|
325
|
+
)
|
|
326
|
+
persons = result.get("results", [])
|
|
327
|
+
if not persons:
|
|
328
|
+
raise ValueError(f"No person found for distinct_id={distinct_id}")
|
|
329
|
+
return persons[0]
|
|
330
|
+
|
|
331
|
+
async def list_persons(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
|
|
332
|
+
"""List persons with pagination."""
|
|
333
|
+
return await self._request(
|
|
334
|
+
"GET",
|
|
335
|
+
"/persons/",
|
|
336
|
+
params={"limit": limit, "offset": offset},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# -- Actions ----------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
async def list_actions(self, limit: int = 100) -> dict[str, Any]:
|
|
342
|
+
"""List defined actions."""
|
|
343
|
+
return await self._request("GET", "/actions/", params={"limit": limit})
|
|
344
|
+
|
|
345
|
+
# -- HogQL / Query API -----------------------------------------------
|
|
346
|
+
|
|
347
|
+
async def event_volumes(self, days: int = 7, limit: int = 50) -> list[tuple[str, int]]:
|
|
348
|
+
"""Return [(event_name, count), ...] for the top events in the period.
|
|
349
|
+
|
|
350
|
+
Used by Cyra to auto-detect a funnel candidate (highest-volume
|
|
351
|
+
$pageview to custom_event chain).
|
|
352
|
+
"""
|
|
353
|
+
query = {
|
|
354
|
+
"kind": "EventsQuery",
|
|
355
|
+
"select": ["event", "count()"],
|
|
356
|
+
"after": f"-{days}d",
|
|
357
|
+
"orderBy": ["-count()"],
|
|
358
|
+
"limit": limit,
|
|
359
|
+
}
|
|
360
|
+
data = await self._request("POST", "/query/", json={"query": query})
|
|
361
|
+
results = data.get("results", [])
|
|
362
|
+
return [(row[0], int(row[1])) for row in results]
|
|
363
|
+
|
|
364
|
+
async def funnel_query(
|
|
365
|
+
self,
|
|
366
|
+
events: list[str],
|
|
367
|
+
days: int = 7,
|
|
368
|
+
) -> list[dict]:
|
|
369
|
+
"""Run a funnel query for the given event sequence.
|
|
370
|
+
|
|
371
|
+
Returns one dict per step with keys: name, count, average_conversion_time.
|
|
372
|
+
"""
|
|
373
|
+
query = {
|
|
374
|
+
"kind": "FunnelsQuery",
|
|
375
|
+
"series": [{"event": e, "kind": "EventsNode"} for e in events],
|
|
376
|
+
"dateRange": {"date_from": f"-{days}d"},
|
|
377
|
+
}
|
|
378
|
+
data = await self._request("POST", "/query/", json={"query": query})
|
|
379
|
+
return data.get("results", [])
|
|
380
|
+
|
|
381
|
+
# -- Session Recordings -----------------------------------------------
|
|
382
|
+
|
|
383
|
+
async def list_session_recordings(
|
|
384
|
+
self,
|
|
385
|
+
limit: int = 50,
|
|
386
|
+
offset: int = 0,
|
|
387
|
+
date_from: Optional[str] = None,
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
"""List session recordings."""
|
|
390
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
391
|
+
if date_from:
|
|
392
|
+
params["date_from"] = date_from
|
|
393
|
+
return await self._request("GET", "/session_recordings/", params=params)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# tools/apollo_client.py
|
|
2
|
+
"""
|
|
3
|
+
Apollo.io API async client.
|
|
4
|
+
|
|
5
|
+
Provides typed async access to Apollo's REST API for:
|
|
6
|
+
- People search (find contacts by title, company, industry)
|
|
7
|
+
- Organization search (find companies by criteria)
|
|
8
|
+
- Person enrichment (enrich by email or LinkedIn URL)
|
|
9
|
+
- Organization enrichment (enrich by domain)
|
|
10
|
+
|
|
11
|
+
Authentication: x-api-key header.
|
|
12
|
+
Rate limits: ~50 RPM standard plan; handled via tenacity retry on 429.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from tenacity import (
|
|
21
|
+
retry,
|
|
22
|
+
retry_if_exception_type,
|
|
23
|
+
stop_after_attempt,
|
|
24
|
+
wait_exponential,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from devrel_origin.tools.instantly_client import InstantlyLead
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Errors
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ApolloAPIError(Exception):
|
|
39
|
+
"""Non-retryable error from the Apollo API (4xx except 429)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, status_code: int, detail: str):
|
|
42
|
+
self.status_code = status_code
|
|
43
|
+
self.detail = detail
|
|
44
|
+
super().__init__(f"Apollo API error {status_code}: {detail}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Data Transfer Objects
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ApolloContact:
|
|
54
|
+
"""A contact/person from Apollo."""
|
|
55
|
+
|
|
56
|
+
id: str
|
|
57
|
+
first_name: str
|
|
58
|
+
last_name: str
|
|
59
|
+
email: str | None = None
|
|
60
|
+
title: str | None = None
|
|
61
|
+
company_name: str | None = None
|
|
62
|
+
company_domain: str | None = None
|
|
63
|
+
linkedin_url: str | None = None
|
|
64
|
+
phone: str | None = None
|
|
65
|
+
|
|
66
|
+
def to_instantly_lead(self) -> "InstantlyLead":
|
|
67
|
+
"""Convert to InstantlyLead for Instantly upload.
|
|
68
|
+
|
|
69
|
+
Field mapping:
|
|
70
|
+
- email -> email (empty string if None)
|
|
71
|
+
- first_name -> first_name
|
|
72
|
+
- last_name -> last_name
|
|
73
|
+
- company_name -> company_name
|
|
74
|
+
- phone, linkedin_url, title -> custom_variables (only if set)
|
|
75
|
+
"""
|
|
76
|
+
from devrel_origin.tools.instantly_client import InstantlyLead
|
|
77
|
+
|
|
78
|
+
return InstantlyLead(
|
|
79
|
+
email=self.email or "",
|
|
80
|
+
first_name=self.first_name,
|
|
81
|
+
last_name=self.last_name,
|
|
82
|
+
company_name=self.company_name or "",
|
|
83
|
+
custom_variables={
|
|
84
|
+
k: v
|
|
85
|
+
for k, v in {
|
|
86
|
+
"phone": self.phone,
|
|
87
|
+
"linkedin_url": self.linkedin_url,
|
|
88
|
+
"title": self.title,
|
|
89
|
+
}.items()
|
|
90
|
+
if v
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ApolloOrganization:
|
|
97
|
+
"""An organization from Apollo."""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
name: str
|
|
101
|
+
domain: str | None = None
|
|
102
|
+
industry: str | None = None
|
|
103
|
+
estimated_headcount: int | None = None
|
|
104
|
+
tech_stack: list[str] = field(default_factory=list)
|
|
105
|
+
funding_stage: str | None = None
|
|
106
|
+
funding_total: float | None = None
|
|
107
|
+
description: str | None = None
|
|
108
|
+
linkedin_url: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class PeopleSearchResult:
|
|
113
|
+
"""Result from people search endpoint."""
|
|
114
|
+
|
|
115
|
+
contacts: list[ApolloContact]
|
|
116
|
+
total: int
|
|
117
|
+
page: int
|
|
118
|
+
per_page: int
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class OrgSearchResult:
|
|
123
|
+
"""Result from organization search endpoint."""
|
|
124
|
+
|
|
125
|
+
organizations: list[ApolloOrganization]
|
|
126
|
+
total: int
|
|
127
|
+
page: int
|
|
128
|
+
per_page: int
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Client
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ApolloClient:
|
|
137
|
+
"""Async client for the Apollo.io REST API."""
|
|
138
|
+
|
|
139
|
+
BASE_URL = "https://api.apollo.io/v1"
|
|
140
|
+
|
|
141
|
+
def __init__(self, api_key: str):
|
|
142
|
+
self._api_key = api_key
|
|
143
|
+
self._client = httpx.AsyncClient(
|
|
144
|
+
base_url=self.BASE_URL,
|
|
145
|
+
headers={"x-api-key": api_key, "Content-Type": "application/json"},
|
|
146
|
+
timeout=30.0,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def close(self) -> None:
|
|
150
|
+
await self._client.aclose()
|
|
151
|
+
|
|
152
|
+
async def __aenter__(self) -> "ApolloClient":
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
156
|
+
await self.close()
|
|
157
|
+
|
|
158
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
159
|
+
if response.status_code == 429:
|
|
160
|
+
raise httpx.HTTPStatusError("Rate limited", request=response.request, response=response)
|
|
161
|
+
if response.status_code >= 400:
|
|
162
|
+
try:
|
|
163
|
+
detail = response.json().get("message", response.text)
|
|
164
|
+
except Exception:
|
|
165
|
+
detail = response.text
|
|
166
|
+
raise ApolloAPIError(status_code=response.status_code, detail=detail)
|
|
167
|
+
|
|
168
|
+
@retry(
|
|
169
|
+
stop=stop_after_attempt(3),
|
|
170
|
+
wait=wait_exponential(multiplier=2, min=4, max=60),
|
|
171
|
+
retry=retry_if_exception_type(httpx.HTTPStatusError),
|
|
172
|
+
)
|
|
173
|
+
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
174
|
+
response = await self._client.post(path, json=payload)
|
|
175
|
+
self._raise_for_status(response)
|
|
176
|
+
return response.json()
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _parse_contact(data: dict[str, Any]) -> ApolloContact:
|
|
180
|
+
return ApolloContact(
|
|
181
|
+
id=data.get("id", ""),
|
|
182
|
+
first_name=data.get("first_name", ""),
|
|
183
|
+
last_name=data.get("last_name", ""),
|
|
184
|
+
email=data.get("email"),
|
|
185
|
+
title=data.get("title"),
|
|
186
|
+
company_name=data.get("organization_name") or data.get("company_name"),
|
|
187
|
+
company_domain=data.get("organization", {}).get("primary_domain")
|
|
188
|
+
if isinstance(data.get("organization"), dict)
|
|
189
|
+
else data.get("company_domain"),
|
|
190
|
+
linkedin_url=data.get("linkedin_url"),
|
|
191
|
+
phone=data.get("sanitized_phone") or data.get("phone"),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _parse_organization(data: dict[str, Any]) -> ApolloOrganization:
|
|
196
|
+
tech = data.get("technologies") or data.get("tech_stack") or []
|
|
197
|
+
if isinstance(tech, list):
|
|
198
|
+
tech_names = [t if isinstance(t, str) else t.get("name", "") for t in tech]
|
|
199
|
+
else:
|
|
200
|
+
tech_names = []
|
|
201
|
+
|
|
202
|
+
return ApolloOrganization(
|
|
203
|
+
id=data.get("id", ""),
|
|
204
|
+
name=data.get("name", ""),
|
|
205
|
+
domain=data.get("primary_domain") or data.get("domain"),
|
|
206
|
+
industry=data.get("industry"),
|
|
207
|
+
estimated_headcount=data.get("estimated_num_employees"),
|
|
208
|
+
tech_stack=tech_names,
|
|
209
|
+
funding_stage=data.get("latest_funding_stage") or data.get("funding_stage"),
|
|
210
|
+
funding_total=data.get("total_funding"),
|
|
211
|
+
description=data.get("short_description") or data.get("description"),
|
|
212
|
+
linkedin_url=data.get("linkedin_url"),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def search_people(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
titles: list[str] | None = None,
|
|
219
|
+
domains: list[str] | None = None,
|
|
220
|
+
industries: list[str] | None = None,
|
|
221
|
+
page: int = 1,
|
|
222
|
+
per_page: int = 25,
|
|
223
|
+
**extra: Any,
|
|
224
|
+
) -> PeopleSearchResult:
|
|
225
|
+
"""Search for people by title, domain, or industry."""
|
|
226
|
+
payload: dict[str, Any] = {"page": page, "per_page": min(per_page, 100)}
|
|
227
|
+
if titles:
|
|
228
|
+
payload["person_titles"] = titles
|
|
229
|
+
if domains:
|
|
230
|
+
payload["q_organization_domains"] = domains
|
|
231
|
+
if industries:
|
|
232
|
+
payload["organization_industry_tag_ids"] = industries
|
|
233
|
+
payload.update(extra)
|
|
234
|
+
|
|
235
|
+
data = await self._post("/mixed_people/api_search", payload)
|
|
236
|
+
contacts = [self._parse_contact(c) for c in data.get("people", [])]
|
|
237
|
+
pagination = data.get("pagination", {})
|
|
238
|
+
return PeopleSearchResult(
|
|
239
|
+
contacts=contacts,
|
|
240
|
+
total=pagination.get("total_entries", data.get("total_entries", len(contacts))),
|
|
241
|
+
page=pagination.get("page", page),
|
|
242
|
+
per_page=pagination.get("per_page", per_page),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def search_organizations(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
industries: list[str] | None = None,
|
|
249
|
+
min_headcount: int | None = None,
|
|
250
|
+
max_headcount: int | None = None,
|
|
251
|
+
page: int = 1,
|
|
252
|
+
per_page: int = 25,
|
|
253
|
+
**extra: Any,
|
|
254
|
+
) -> OrgSearchResult:
|
|
255
|
+
"""Search for organizations by industry and headcount range."""
|
|
256
|
+
payload: dict[str, Any] = {"page": page, "per_page": per_page}
|
|
257
|
+
if industries:
|
|
258
|
+
payload["organization_industry_tag_ids"] = industries
|
|
259
|
+
if min_headcount is not None or max_headcount is not None:
|
|
260
|
+
payload["organization_num_employees_ranges"] = [
|
|
261
|
+
f"{min_headcount or 1},{max_headcount or 100_000}"
|
|
262
|
+
]
|
|
263
|
+
payload.update(extra)
|
|
264
|
+
|
|
265
|
+
data = await self._post("/mixed_companies/search", payload)
|
|
266
|
+
orgs = [self._parse_organization(o) for o in data.get("organizations", [])]
|
|
267
|
+
pagination = data.get("pagination", {})
|
|
268
|
+
return OrgSearchResult(
|
|
269
|
+
organizations=orgs,
|
|
270
|
+
total=pagination.get("total_entries", len(orgs)),
|
|
271
|
+
page=pagination.get("page", page),
|
|
272
|
+
per_page=pagination.get("per_page", per_page),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def enrich_person(
|
|
276
|
+
self,
|
|
277
|
+
*,
|
|
278
|
+
person_id: str | None = None,
|
|
279
|
+
email: str | None = None,
|
|
280
|
+
linkedin_url: str | None = None,
|
|
281
|
+
) -> ApolloContact | None:
|
|
282
|
+
"""Enrich a person by ID, email, or LinkedIn URL. Returns None if not found."""
|
|
283
|
+
if not person_id and not email and not linkedin_url:
|
|
284
|
+
raise ValueError("Provide at least one of person_id, email, or linkedin_url")
|
|
285
|
+
payload: dict[str, Any] = {}
|
|
286
|
+
if person_id:
|
|
287
|
+
payload["id"] = person_id
|
|
288
|
+
if email:
|
|
289
|
+
payload["email"] = email
|
|
290
|
+
if linkedin_url:
|
|
291
|
+
payload["linkedin_url"] = linkedin_url
|
|
292
|
+
|
|
293
|
+
data = await self._post("/people/match", payload)
|
|
294
|
+
person = data.get("person")
|
|
295
|
+
if not person:
|
|
296
|
+
return None
|
|
297
|
+
return self._parse_contact(person)
|
|
298
|
+
|
|
299
|
+
async def enrich_organization(self, *, domain: str) -> ApolloOrganization | None:
|
|
300
|
+
"""Enrich an organization by domain. Returns None if not found."""
|
|
301
|
+
data = await self._post("/organizations/enrich", {"domain": domain})
|
|
302
|
+
org = data.get("organization")
|
|
303
|
+
if not org:
|
|
304
|
+
return None
|
|
305
|
+
return self._parse_organization(org)
|