orangeslice 2.4.1 → 2.6.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/dist/api.d.ts +9 -0
- package/dist/api.js +90 -10
- package/dist/cli.js +0 -0
- package/dist/ctx.d.ts +15 -0
- package/dist/ctx.js +1 -0
- package/dist/expansion.js +3 -0
- package/dist/generateObject.js +7 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/docs/integrations/index.md +48 -0
- package/docs/integrations/websiteTracking/index.md +181 -0
- package/docs/integrations/websiteTracking/setupTracking.md +142 -0
- package/docs/services/ctx/index.md +22 -0
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -3,4 +3,13 @@ export interface OrangesliceConfig {
|
|
|
3
3
|
baseUrl?: string;
|
|
4
4
|
}
|
|
5
5
|
export declare function configure(opts: OrangesliceConfig): void;
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` with a per-call apiKey scoped via AsyncLocalStorage. Inside `fn`,
|
|
8
|
+
* `resolveApiKey()` returns this apiKey instead of falling back to the module
|
|
9
|
+
* singleton/env/config-file. Useful for server-side consumers (like the MCP
|
|
10
|
+
* route) that handle multiple users concurrently within one process.
|
|
11
|
+
*
|
|
12
|
+
* No-op on non-Node runtimes (falls back to the standard resolution chain).
|
|
13
|
+
*/
|
|
14
|
+
export declare function withApiKey<T>(apiKey: string, fn: () => T | Promise<T>): Promise<T>;
|
|
6
15
|
export declare function post<T>(endpoint: string, payload: Record<string, unknown>): Promise<T>;
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.configure = configure;
|
|
4
|
+
exports.withApiKey = withApiKey;
|
|
4
5
|
exports.post = post;
|
|
5
6
|
/**
|
|
6
7
|
* Transport layer for orangeslice SDK.
|
|
@@ -14,16 +15,58 @@ const DEFAULT_INLINE_WAIT_MS = 5000;
|
|
|
14
15
|
const USER_CONFIG_PATH = ".config/orangeslice/config.json";
|
|
15
16
|
const _config = {};
|
|
16
17
|
function configure(opts) {
|
|
17
|
-
if (opts
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
|
|
19
|
+
throw new Error("[orangeslice] configure() expects an object, e.g. configure({ apiKey: 'osk_...' })");
|
|
20
|
+
}
|
|
21
|
+
_config.apiKey = opts.apiKey;
|
|
22
|
+
_config.baseUrl = opts.baseUrl;
|
|
23
|
+
}
|
|
24
|
+
// AsyncLocalStorage is Node-only. Browser-style runtimes get a no-op shim so
|
|
25
|
+
// that `withApiKey` doesn't crash; calls outside Node fall back to the
|
|
26
|
+
// existing singleton/env/file resolution chain.
|
|
27
|
+
let _callContextStore = null;
|
|
28
|
+
function getCallContextStore() {
|
|
29
|
+
if (_callContextStore)
|
|
30
|
+
return _callContextStore;
|
|
31
|
+
if (!process?.versions?.node) {
|
|
32
|
+
_callContextStore = {
|
|
33
|
+
getStore: () => undefined,
|
|
34
|
+
run: (_ctx, fn) => fn()
|
|
35
|
+
};
|
|
36
|
+
return _callContextStore;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const { AsyncLocalStorage } = require("node:async_hooks");
|
|
40
|
+
_callContextStore = new AsyncLocalStorage();
|
|
41
|
+
return _callContextStore;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
_callContextStore = {
|
|
45
|
+
getStore: () => undefined,
|
|
46
|
+
run: (_ctx, fn) => fn()
|
|
47
|
+
};
|
|
48
|
+
return _callContextStore;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Run `fn` with a per-call apiKey scoped via AsyncLocalStorage. Inside `fn`,
|
|
53
|
+
* `resolveApiKey()` returns this apiKey instead of falling back to the module
|
|
54
|
+
* singleton/env/config-file. Useful for server-side consumers (like the MCP
|
|
55
|
+
* route) that handle multiple users concurrently within one process.
|
|
56
|
+
*
|
|
57
|
+
* No-op on non-Node runtimes (falls back to the standard resolution chain).
|
|
58
|
+
*/
|
|
59
|
+
function withApiKey(apiKey, fn) {
|
|
60
|
+
const store = getCallContextStore();
|
|
61
|
+
return Promise.resolve().then(() => store.run({ apiKey }, fn));
|
|
21
62
|
}
|
|
22
63
|
function resolveBaseUrl() {
|
|
23
|
-
|
|
64
|
+
const ctx = getCallContextStore().getStore();
|
|
65
|
+
return (ctx?.baseUrl || _config.baseUrl || process.env.ORANGESLICE_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
24
66
|
}
|
|
25
67
|
function resolveApiKey() {
|
|
26
|
-
|
|
68
|
+
const ctx = getCallContextStore().getStore();
|
|
69
|
+
return ctx?.apiKey || _config.apiKey || process.env.ORANGESLICE_API_KEY || readApiKeyFromConfigFile() || "";
|
|
27
70
|
}
|
|
28
71
|
function readApiKeyFromConfigFile() {
|
|
29
72
|
// Guard for browser-like runtimes.
|
|
@@ -95,16 +138,50 @@ function resolvePollUrl(baseUrl, pending) {
|
|
|
95
138
|
}
|
|
96
139
|
return `${baseUrl}/function/result/${pending.requestId}`;
|
|
97
140
|
}
|
|
141
|
+
function sameOrigin(a, b) {
|
|
142
|
+
try {
|
|
143
|
+
const ua = new URL(a);
|
|
144
|
+
const ub = new URL(b);
|
|
145
|
+
return ua.protocol === ub.protocol && ua.host === ub.host;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// If either URL is unparseable, treat as cross-origin (fail closed).
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
98
152
|
async function fetchWithRedirect(url, init) {
|
|
99
153
|
let res = await fetch(url, { ...init, redirect: "manual" });
|
|
100
154
|
if (res.status >= 300 && res.status < 400) {
|
|
101
155
|
const location = res.headers.get("location");
|
|
102
156
|
if (location) {
|
|
103
|
-
|
|
157
|
+
// Resolve the redirect target against the original URL so a relative
|
|
158
|
+
// `Location` header is handled correctly.
|
|
159
|
+
const resolved = new URL(location, url).toString();
|
|
160
|
+
// Cross-origin redirects must not receive the `Authorization` header
|
|
161
|
+
// (or any other auth-bearing headers) — otherwise a misconfigured /
|
|
162
|
+
// malicious redirect could exfiltrate the user's `osk_` API key to an
|
|
163
|
+
// arbitrary host.
|
|
164
|
+
const nextInit = sameOrigin(url, resolved) ? { ...init } : stripAuthHeaders(init);
|
|
165
|
+
res = await fetch(resolved, nextInit);
|
|
104
166
|
}
|
|
105
167
|
}
|
|
106
168
|
return res;
|
|
107
169
|
}
|
|
170
|
+
function stripAuthHeaders(init) {
|
|
171
|
+
const headers = new Headers(init.headers ?? {});
|
|
172
|
+
headers.delete("authorization");
|
|
173
|
+
headers.delete("cookie");
|
|
174
|
+
headers.delete("proxy-authorization");
|
|
175
|
+
return { ...init, headers };
|
|
176
|
+
}
|
|
177
|
+
function formatRequestError(endpoint, status, message) {
|
|
178
|
+
// 4xx → caller-fixable problem: surface just the server's message so it reads
|
|
179
|
+
// like a normal validation error (e.g. `Cannot resolve sheet "leads"`).
|
|
180
|
+
// 5xx → infra-shaped problem: keep the noisy prefix so callers can grep logs.
|
|
181
|
+
if (status >= 400 && status < 500)
|
|
182
|
+
return message;
|
|
183
|
+
return `[orangeslice] ${endpoint}: ${status} ${message}`;
|
|
184
|
+
}
|
|
108
185
|
async function pollUntilComplete(baseUrl, endpoint, pending) {
|
|
109
186
|
const pollUrl = resolvePollUrl(baseUrl, pending);
|
|
110
187
|
const timeoutAt = Date.now() + POLL_TIMEOUT_MS;
|
|
@@ -122,11 +199,14 @@ async function pollUntilComplete(baseUrl, endpoint, pending) {
|
|
|
122
199
|
}
|
|
123
200
|
if (!res.ok) {
|
|
124
201
|
const message = asErrorMessage(data) || JSON.stringify(data);
|
|
125
|
-
throw new Error(
|
|
202
|
+
throw new Error(formatRequestError(endpoint, res.status, message));
|
|
126
203
|
}
|
|
127
204
|
const message = asErrorMessage(data);
|
|
128
205
|
if (message) {
|
|
129
|
-
|
|
206
|
+
// 2xx body carrying an `error` field is semantically a caller-fixable
|
|
207
|
+
// error delivered asynchronously — treat it like a 4xx so the message
|
|
208
|
+
// surfaces cleanly without the `[orangeslice] /endpoint: …` prefix.
|
|
209
|
+
throw new Error(formatRequestError(endpoint, 400, message));
|
|
130
210
|
}
|
|
131
211
|
return data;
|
|
132
212
|
}
|
|
@@ -153,7 +233,7 @@ async function post(endpoint, payload) {
|
|
|
153
233
|
if (!res.ok) {
|
|
154
234
|
const data = await readResponseBody(res);
|
|
155
235
|
const message = asErrorMessage(data) || (typeof data === "string" ? data : JSON.stringify(data));
|
|
156
|
-
throw new Error(
|
|
236
|
+
throw new Error(formatRequestError(endpoint, res.status, message));
|
|
157
237
|
}
|
|
158
238
|
const data = await readResponseBody(res);
|
|
159
239
|
if (isPendingResponse(data)) {
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/ctx.d.ts
CHANGED
|
@@ -25,6 +25,20 @@ export interface SpreadsheetListItem {
|
|
|
25
25
|
orgId: string | null;
|
|
26
26
|
userId: string | null;
|
|
27
27
|
}
|
|
28
|
+
export interface SpreadsheetDescribeColumn {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
}
|
|
32
|
+
export interface SpreadsheetDescribeSheet {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
columns: SpreadsheetDescribeColumn[];
|
|
36
|
+
}
|
|
37
|
+
export interface SpreadsheetDescribeResult {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
sheets: SpreadsheetDescribeSheet[];
|
|
41
|
+
}
|
|
28
42
|
export interface SqlQueryResult {
|
|
29
43
|
rows: Record<string, unknown>[];
|
|
30
44
|
rowIds: string[];
|
|
@@ -61,6 +75,7 @@ export declare const ctx: {
|
|
|
61
75
|
sql: (spreadsheetId: string, sql: string) => Promise<SqlResult>;
|
|
62
76
|
spreadsheet: (spreadsheetId: string) => {
|
|
63
77
|
sql: (sql: string) => Promise<SqlResult>;
|
|
78
|
+
describe: () => Promise<SpreadsheetDescribeResult>;
|
|
64
79
|
sheet: (sheetName: string) => {
|
|
65
80
|
addRows: (rows: Record<string, unknown> | Record<string, unknown>[]) => Promise<RowsAddResult>;
|
|
66
81
|
};
|
package/dist/ctx.js
CHANGED
|
@@ -44,6 +44,7 @@ function createSpreadsheetHandle(spreadsheetId) {
|
|
|
44
44
|
assertNotRun(sql);
|
|
45
45
|
return (0, api_1.post)("/ctx/sql", { spreadsheetId, sql });
|
|
46
46
|
},
|
|
47
|
+
describe: () => (0, api_1.post)("/ctx/spreadsheet/describe", { spreadsheetId }),
|
|
47
48
|
sheet: (sheetName) => createSheetHandle(spreadsheetId, sheetName)
|
|
48
49
|
};
|
|
49
50
|
}
|
package/dist/expansion.js
CHANGED
|
@@ -32,6 +32,9 @@ async function personLinkedinEnrich(params) {
|
|
|
32
32
|
if (!url && !username) {
|
|
33
33
|
throw new Error("[orangeslice] person.linkedin.enrich: provide url or username");
|
|
34
34
|
}
|
|
35
|
+
if (url && !username) {
|
|
36
|
+
throw new Error(`[orangeslice] person.linkedin.enrich: URL must be a personal LinkedIn profile (linkedin.com/in/<username>), got: ${url}`);
|
|
37
|
+
}
|
|
35
38
|
const sql = extended
|
|
36
39
|
? `SELECT lkd.*
|
|
37
40
|
FROM linkedin_profile_slug ps
|
package/dist/generateObject.js
CHANGED
|
@@ -21,9 +21,14 @@ const api_1 = require("./api");
|
|
|
21
21
|
* // { object: { company: "Apple Inc", year: 1976, founder: "Steve Jobs" } }
|
|
22
22
|
*/
|
|
23
23
|
async function generateObject(options) {
|
|
24
|
-
|
|
24
|
+
const payload = {
|
|
25
25
|
mode: "object",
|
|
26
26
|
prompt: options.prompt,
|
|
27
27
|
schemaJson: options.schema
|
|
28
|
-
}
|
|
28
|
+
};
|
|
29
|
+
if (options.system !== undefined)
|
|
30
|
+
payload.system = options.system;
|
|
31
|
+
if (options.model !== undefined)
|
|
32
|
+
payload.model = options.model;
|
|
33
|
+
return (0, api_1.post)("/execute/llm", payload);
|
|
29
34
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { configure } from "./api";
|
|
1
|
+
export { configure, withApiKey } from "./api";
|
|
2
2
|
export type { OrangesliceConfig } from "./api";
|
|
3
3
|
export { integrations } from "./integrations";
|
|
4
4
|
export type { Integration, IntegrationProvider, IntegrationListParams, IntegrationCreateParams, IntegrationUpdateParams, IntegrationConnectOptions, IntegrationExecuteOptions } from "./integrations";
|
|
@@ -7,7 +7,7 @@ export type { Skill, SkillListParams, SkillCreateParams, SkillUpdateParams } fro
|
|
|
7
7
|
export { findCareersPage, scrapeCareersPage } from "./careers";
|
|
8
8
|
export type { FindCareersPageParams, FindCareersPageResult, ScrapeCareersPageParams, ScrapeCareersPageResult, ScrapeCareersPageJob } from "./careers";
|
|
9
9
|
export { ctx } from "./ctx";
|
|
10
|
-
export type { Spreadsheet, SpreadsheetListItem, SqlResult, SqlQueryResult, SqlActionResult, RowsAddResult } from "./ctx";
|
|
10
|
+
export type { Spreadsheet, SpreadsheetListItem, SpreadsheetDescribeResult, SpreadsheetDescribeSheet, SpreadsheetDescribeColumn, SqlResult, SqlQueryResult, SqlActionResult, RowsAddResult } from "./ctx";
|
|
11
11
|
export { linkedinSearch } from "./b2b";
|
|
12
12
|
export type { LinkedInSearchParams, LinkedInSearchResponse } from "./b2b";
|
|
13
13
|
export { crunchbaseSearch } from "./crunchbase";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.services = exports.builtWithSearchByTech = exports.builtWithRelationships = exports.builtWithLookupDomain = exports.geoParseAddress = exports.companyGetEmployeesFromLinkedin = exports.companyLinkedinFindUrl = exports.companyLinkedinEnrich = exports.personContactGet = exports.personLinkedinFindUrl = exports.personLinkedinEnrich = exports.PREDICT_LEADS_OPERATION_IDS = exports.predictLeads = exports.executePredictLeads = exports.googleMapsScrape = exports.runApifyActor = exports.browserExecute = exports.scrapeWebsite = exports.generateObject = exports.webBatchSearch = exports.webSearch = exports.OCEAN_OPERATION_IDS = exports.oceanSearchPeople = exports.oceanSearchCompanies = exports.executeOcean = exports.crunchbaseSearch = exports.linkedinSearch = exports.ctx = exports.scrapeCareersPage = exports.findCareersPage = exports.skills = exports.integrations = exports.configure = void 0;
|
|
3
|
+
exports.services = exports.builtWithSearchByTech = exports.builtWithRelationships = exports.builtWithLookupDomain = exports.geoParseAddress = exports.companyGetEmployeesFromLinkedin = exports.companyLinkedinFindUrl = exports.companyLinkedinEnrich = exports.personContactGet = exports.personLinkedinFindUrl = exports.personLinkedinEnrich = exports.PREDICT_LEADS_OPERATION_IDS = exports.predictLeads = exports.executePredictLeads = exports.googleMapsScrape = exports.runApifyActor = exports.browserExecute = exports.scrapeWebsite = exports.generateObject = exports.webBatchSearch = exports.webSearch = exports.OCEAN_OPERATION_IDS = exports.oceanSearchPeople = exports.oceanSearchCompanies = exports.executeOcean = exports.crunchbaseSearch = exports.linkedinSearch = exports.ctx = exports.scrapeCareersPage = exports.findCareersPage = exports.skills = exports.integrations = exports.withApiKey = exports.configure = void 0;
|
|
4
4
|
var api_1 = require("./api");
|
|
5
5
|
Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return api_1.configure; } });
|
|
6
|
+
Object.defineProperty(exports, "withApiKey", { enumerable: true, get: function () { return api_1.withApiKey; } });
|
|
6
7
|
var integrations_1 = require("./integrations");
|
|
7
8
|
Object.defineProperty(exports, "integrations", { enumerable: true, get: function () { return integrations_1.integrations; } });
|
|
8
9
|
var skills_1 = require("./skills");
|
|
@@ -240,6 +240,54 @@ await integrations.attio.createNote({
|
|
|
240
240
|
|
|
241
241
|
See [attio/](./attio/) for all available functions.
|
|
242
242
|
|
|
243
|
+
### Website tracking
|
|
244
|
+
|
|
245
|
+
LinkedIn-person identification for US-based website visitors. Customers
|
|
246
|
+
paste a single `<script>` tag on their site; each identified visit lands
|
|
247
|
+
as a row on the spreadsheet that owns the trigger.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
// Enroll a single domain — creates the trigger + sheet ingester, registers
|
|
251
|
+
// the domain in our master webhook routing table, adds it to the upstream
|
|
252
|
+
// allow-list, and returns the <script> the user pastes on their site.
|
|
253
|
+
const { script } = await integrations.websiteTracking.setupTracking({
|
|
254
|
+
domain: "acme.com",
|
|
255
|
+
sheetName: "Website Visitors"
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Inspect later
|
|
259
|
+
const tracked = await integrations.websiteTracking.listTrackedDomains();
|
|
260
|
+
|
|
261
|
+
// "Stop on THIS sheet only" — keeps the upstream allow-list up and
|
|
262
|
+
// any other Orange Slice spreadsheets that enrolled the same domain
|
|
263
|
+
// keep receiving visitor rows.
|
|
264
|
+
await integrations.websiteTracking.detachTracking("acme.com");
|
|
265
|
+
|
|
266
|
+
// "Stop tracking this domain on all my spreadsheets" — deletes every
|
|
267
|
+
// Website Visitors trigger + mapping for the domain across all of
|
|
268
|
+
// this account's spreadsheets. Other accounts that enrolled the same
|
|
269
|
+
// domain are unaffected; the upstream allow-list is only dropped when
|
|
270
|
+
// no other account still needs it.
|
|
271
|
+
await integrations.websiteTracking.removeTracking("acme.com");
|
|
272
|
+
|
|
273
|
+
// After a customer tops up, re-enable any paused mappings:
|
|
274
|
+
const { resumed, alreadyActive, failures } =
|
|
275
|
+
await integrations.websiteTracking.resumeTracking();
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Pricing: **80 credits per identified visitor** (charged at the master
|
|
279
|
+
webhook). Charging happens ONCE per ping even when the same account
|
|
280
|
+
enrolled the domain on multiple spreadsheets — the row fans out to
|
|
281
|
+
every matching trigger under a single credit reservation. Repeat
|
|
282
|
+
visits and company-only pings are forwarded to the sheet but don't
|
|
283
|
+
cost credits. When the account runs out of credits the domain is
|
|
284
|
+
automatically paused. **Resume is not automatic** — call
|
|
285
|
+
`resumeTracking()` (optionally scoped to one domain) once the customer
|
|
286
|
+
has topped up, otherwise the next top-up won't bring tracking back online.
|
|
287
|
+
|
|
288
|
+
See [websiteTracking/](./websiteTracking/) for the full recipe,
|
|
289
|
+
canonical visitor shape, and trigger code template.
|
|
290
|
+
|
|
243
291
|
### Gmail
|
|
244
292
|
|
|
245
293
|
Read and write emails from connected Google Gmail accounts.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: integrations/websiteTracking
|
|
3
|
+
description: Track website visitors (LinkedIn person identification). Use when a user asks to identify who is visiting their site, build website-intent lists, or trigger outreach from anonymous traffic.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Website Visitor Identification
|
|
7
|
+
|
|
8
|
+
Identify the LinkedIn profile of US-based visitors to a customer's
|
|
9
|
+
website. The customer pastes a single `<script>` tag on their site;
|
|
10
|
+
every identified visit lands as a row on the spreadsheet that owns the
|
|
11
|
+
trigger.
|
|
12
|
+
|
|
13
|
+
- **Per-account tracking script** (`https://www.orangeslice.ai/wt.js?accountId=…`)
|
|
14
|
+
is pasted on the customer's site. The `accountId` round-trips back to
|
|
15
|
+
us as RB2B's `customer_id` on every ping, so even when multiple
|
|
16
|
+
Orange Slice accounts have enrolled the same domain, the ping is
|
|
17
|
+
routed only to the account that actually installed the script on
|
|
18
|
+
that page. No per-customer external account, no logins, no separate
|
|
19
|
+
billing. The script is provider-agnostic — if Orange Slice ever
|
|
20
|
+
swaps the underlying identification provider, the customer's pasted
|
|
21
|
+
snippet keeps working without any change.
|
|
22
|
+
- Every visitor ping fans out to a **master webhook** we own. The
|
|
23
|
+
master webhook looks up `(captured domain, customer_id)`, charges the
|
|
24
|
+
customer's Orange Slice credit balance ONCE, then forwards the
|
|
25
|
+
visitor record to every trigger that this account enrolled for the
|
|
26
|
+
domain (one ping → one charge → N sheet rows when the same account
|
|
27
|
+
enrolled the domain on N spreadsheets).
|
|
28
|
+
|
|
29
|
+
## Pricing
|
|
30
|
+
|
|
31
|
+
**80 credits per non-repeat visitor.** Every distinct visit Orange
|
|
32
|
+
Slice's tracking partner reports is charged, including:
|
|
33
|
+
|
|
34
|
+
- Full identifications (LinkedIn URL + first name resolved), AND
|
|
35
|
+
- Company-only matches (the visiting company is resolved but no
|
|
36
|
+
specific person).
|
|
37
|
+
|
|
38
|
+
Repeat visits from the same person/device are forwarded to the sheet
|
|
39
|
+
for context but NEVER charge credits.
|
|
40
|
+
|
|
41
|
+
Every row written to the sheet still includes whatever fields the
|
|
42
|
+
upstream resolved — column code can branch on `v.linkedinUrl !== null`
|
|
43
|
+
if the customer wants to filter out company-only rows downstream.
|
|
44
|
+
|
|
45
|
+
When the customer's balance hits zero, the master webhook automatically
|
|
46
|
+
pauses their domain so they stop incurring charges. **Resume is not
|
|
47
|
+
automatic** — after the customer tops up, the agent must explicitly call
|
|
48
|
+
`integrations.websiteTracking.resumeTracking()` to re-enable tracking.
|
|
49
|
+
The customer never has to re-paste the script.
|
|
50
|
+
|
|
51
|
+
If a customer mentions topping up credits or wanting their tracking
|
|
52
|
+
turned back on, check `listTrackedDomains()` — any mapping with
|
|
53
|
+
`status: "disabled_no_credits"` needs `resumeTracking()` to come back to
|
|
54
|
+
life.
|
|
55
|
+
|
|
56
|
+
## The Recipe (call once per domain)
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const { script, triggerId } = await integrations.websiteTracking.setupTracking({
|
|
60
|
+
domain: "acme.com",
|
|
61
|
+
sheetName: "Website Visitors" // optional, defaults to this
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Hand `script` to the user — they paste it on their site.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`setupTracking` does all of the following atomically:
|
|
68
|
+
|
|
69
|
+
1. Creates the destination sheet on the spreadsheet if it doesn't exist
|
|
70
|
+
yet (matched case-insensitively by name).
|
|
71
|
+
2. Creates a sheet-aware trigger that ingests visitor payloads into that
|
|
72
|
+
sheet (uses `addRows` with `createMissingColumns: true` so the user
|
|
73
|
+
doesn't have to pre-create columns either).
|
|
74
|
+
3. Inserts the domain → trigger mapping into our master routing table.
|
|
75
|
+
4. Calls the upstream allow-list API to register the domain.
|
|
76
|
+
5. Returns the per-account
|
|
77
|
+
`<script src="https://www.orangeslice.ai/wt.js?accountId=…">`
|
|
78
|
+
wrapper for the user to paste on their site. The `accountId` query
|
|
79
|
+
param is what scopes inbound pings to this account so a squatter
|
|
80
|
+
who also enrolled the domain cannot steal traffic from pages that
|
|
81
|
+
load this exact tag.
|
|
82
|
+
|
|
83
|
+
If the same (domain, spreadsheet) is re-enrolled it is idempotent —
|
|
84
|
+
the existing trigger and mapping are reused. If the user previously
|
|
85
|
+
deleted the sheet, the helper re-creates it.
|
|
86
|
+
|
|
87
|
+
## After setupTracking — what to tell the user
|
|
88
|
+
|
|
89
|
+
1. Paste the returned `script` into the `<head>` of their website.
|
|
90
|
+
2. Let them know identified visitors will appear in the chosen sheet
|
|
91
|
+
within ~5 minutes of installing. Pricing: 80 credits per identified
|
|
92
|
+
person.
|
|
93
|
+
3. If they want to enrich rows further, point out that the trigger only
|
|
94
|
+
ingests — they should add **column code** (or just let the AI suggest
|
|
95
|
+
columns) for tasks like "compute company size" or "draft an outreach
|
|
96
|
+
email" so per-row work shows in the UI and is retryable.
|
|
97
|
+
|
|
98
|
+
## Multiple sites, multiple spreadsheets
|
|
99
|
+
|
|
100
|
+
A single account can track many domains, each with its own trigger and
|
|
101
|
+
sheet — call `setupTracking` once per domain.
|
|
102
|
+
|
|
103
|
+
A single account can ALSO track the same domain on multiple
|
|
104
|
+
spreadsheets (for example, sales-ops on one sheet and a marketing
|
|
105
|
+
analyst on another). Each `setupTracking` call on a different
|
|
106
|
+
spreadsheet creates its own trigger + mapping. When a visitor ping
|
|
107
|
+
arrives, **80 credits are charged once** and the visitor row is fanned
|
|
108
|
+
out to every matching trigger.
|
|
109
|
+
|
|
110
|
+
Multiple Orange Slice accounts can also enroll the same domain — there
|
|
111
|
+
is no cross-account block. Routing is disambiguated by the `accountId`
|
|
112
|
+
baked into the `<script>` tag (RB2B receives it as `customer_id` and
|
|
113
|
+
echoes it back on every ping), so pings only land on the account that
|
|
114
|
+
actually installed the pixel.
|
|
115
|
+
|
|
116
|
+
## Inspecting / removing
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// What's tracked on this spreadsheet? Returns active AND paused
|
|
120
|
+
// (`disabled_no_credits`) mappings — check the `status` field.
|
|
121
|
+
const mappings = await integrations.websiteTracking.listTrackedDomains();
|
|
122
|
+
|
|
123
|
+
// "Stop tracking on THIS sheet only" — keep the upstream allow-list
|
|
124
|
+
// up so other spreadsheets / accounts that enrolled the same domain
|
|
125
|
+
// keep receiving visitor rows. The destination sheet keeps the
|
|
126
|
+
// customer's existing visitor rows.
|
|
127
|
+
await integrations.websiteTracking.detachTracking("acme.com");
|
|
128
|
+
|
|
129
|
+
// "Stop tracking this domain on all my spreadsheets" — deletes every
|
|
130
|
+
// Website Visitors trigger + mapping for that domain across ALL of
|
|
131
|
+
// this account's spreadsheets. Other Orange Slice accounts that
|
|
132
|
+
// co-enrolled the same domain are unaffected. The upstream allow-list
|
|
133
|
+
// is only dropped if no other account still has the domain active.
|
|
134
|
+
await integrations.websiteTracking.removeTracking("acme.com");
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Picking between `detachTracking` and `removeTracking`
|
|
138
|
+
|
|
139
|
+
Both are scoped to the calling account — no caller can affect another
|
|
140
|
+
account's mappings, so picking the wrong one is recoverable.
|
|
141
|
+
|
|
142
|
+
- "Remove tracking from this sheet but keep my other sheet running" /
|
|
143
|
+
"I don't want visitors landing here anymore" / "delete this sheet's
|
|
144
|
+
Website Visitors trigger" → **`detachTracking(domain)`** (this
|
|
145
|
+
spreadsheet only).
|
|
146
|
+
- "Turn off tracking for acme.com entirely" / "we sold this domain" /
|
|
147
|
+
"stop all visitor identification for acme.com" →
|
|
148
|
+
**`removeTracking(domain)`** (every spreadsheet under this account).
|
|
149
|
+
- If the user has the same domain on multiple of THEIR spreadsheets
|
|
150
|
+
and the intent is ambiguous, ASK — `removeTracking` will tear down
|
|
151
|
+
all of them, whereas `detachTracking` is per-sheet surgical.
|
|
152
|
+
|
|
153
|
+
## Resuming a paused domain (after a top-up)
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// Re-enable every paused mapping on this spreadsheet:
|
|
157
|
+
const result = await integrations.websiteTracking.resumeTracking();
|
|
158
|
+
|
|
159
|
+
// Or scope to one domain:
|
|
160
|
+
const result = await integrations.websiteTracking.resumeTracking("acme.com");
|
|
161
|
+
|
|
162
|
+
// result shape:
|
|
163
|
+
// {
|
|
164
|
+
// resumed: [{ domain, mappingId }], // flipped back to active
|
|
165
|
+
// alreadyActive: [{ domain, mappingId }], // already running, no-op
|
|
166
|
+
// failures: [{ domain, mappingId, error }], // upstream rejected
|
|
167
|
+
// }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Only mappings currently in `status: "disabled_no_credits"` get resumed.
|
|
171
|
+
Already-active mappings are reported in `alreadyActive` and not touched.
|
|
172
|
+
The upstream allow-list call is idempotent — calling resume on a domain
|
|
173
|
+
the provider already considers active is safe and ends up in
|
|
174
|
+
`alreadyActive` after the conditional DB update.
|
|
175
|
+
|
|
176
|
+
Tell the user how many domains came back online (`result.resumed.length`)
|
|
177
|
+
and surface any `result.failures[].error` messages — those usually mean
|
|
178
|
+
the upstream provider rejected the re-enrollment and need a human look.
|
|
179
|
+
|
|
180
|
+
See [setupTracking.md](./setupTracking.md) for the full input/output
|
|
181
|
+
contract.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: integrations/websiteTracking/setupTracking
|
|
3
|
+
description: Atomic enrollment helper — creates the receiving trigger, registers the domain in the master webhook table, adds the domain to the upstream allow-list, returns the user-facing <script> tag.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# integrations.websiteTracking.setupTracking
|
|
7
|
+
|
|
8
|
+
Enroll one customer domain into website-visitor tracking.
|
|
9
|
+
|
|
10
|
+
## Input
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
{
|
|
14
|
+
domain: string; // e.g. "acme.com" (with or without scheme/www)
|
|
15
|
+
sheetName?: string; // defaults to "Website Visitors"
|
|
16
|
+
triggerName?: string; // defaults to `Website visitors – {domain}`
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The `domain` is normalized: protocol, `www.`, paths, query, and ports are
|
|
21
|
+
stripped before storage. `"https://www.acme.com/pricing"` and `"acme.com"`
|
|
22
|
+
both store as `acme.com`.
|
|
23
|
+
|
|
24
|
+
## Output
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
{
|
|
28
|
+
script: string; // <script> tag for the user to paste (see below)
|
|
29
|
+
domain: string; // normalized domain
|
|
30
|
+
triggerId: string;
|
|
31
|
+
mappingId: string;
|
|
32
|
+
sheetName: string;
|
|
33
|
+
triggerName: string;
|
|
34
|
+
alreadyOwned: boolean; // true if mapping existed before this call
|
|
35
|
+
alreadyOnAllowList: boolean; // true if the upstream allow-list already had the domain
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The returned `script` is the per-account wrapper:
|
|
40
|
+
|
|
41
|
+
```html
|
|
42
|
+
<script src="https://www.orangeslice.ai/wt.js?accountId=<uuid>" async></script>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`/wt.js` is an Orange Slice-owned route that reads the active tracking
|
|
46
|
+
provider at request time and injects the appropriate pixel. The
|
|
47
|
+
`accountId` query param is what scopes inbound pings to the calling
|
|
48
|
+
account (round-trips as RB2B's `customer_id`), so multiple Orange Slice
|
|
49
|
+
accounts can safely co-enroll the same domain without stealing each
|
|
50
|
+
other's traffic. If we ever swap the underlying provider, the
|
|
51
|
+
customer's pasted snippet keeps working without any action on their
|
|
52
|
+
end.
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
- `Invalid domain` — `domain` couldn't be normalized to a host with a TLD.
|
|
57
|
+
- `Concurrent setupTracking for "<x>" on this spreadsheet — retry.` —
|
|
58
|
+
a parallel `setupTracking` for the same (domain, spreadsheet) raced
|
|
59
|
+
ahead; re-run the call to pick up the now-live row idempotently.
|
|
60
|
+
|
|
61
|
+
`setupTracking` no longer blocks cross-account enrollment: any account
|
|
62
|
+
can enroll any domain, and a single account can enroll the same domain
|
|
63
|
+
on multiple spreadsheets.
|
|
64
|
+
|
|
65
|
+
## What the trigger does
|
|
66
|
+
|
|
67
|
+
The trigger code generated by this helper stays thin per the
|
|
68
|
+
[triggers-runtime.md](../../triggers-runtime.md) rule: it just normalizes
|
|
69
|
+
the upstream payload into the canonical visitor shape and pushes a row
|
|
70
|
+
into the user's chosen sheet with `createMissingColumns: true` and
|
|
71
|
+
`run: true`. Any enrichment / scoring belongs in column code on that
|
|
72
|
+
sheet.
|
|
73
|
+
|
|
74
|
+
The destination sheet is **created automatically** if it doesn't already
|
|
75
|
+
exist on the spreadsheet — there's no pre-flight "make the sheet first"
|
|
76
|
+
step the agent has to do. Existing sheets are matched case-insensitively
|
|
77
|
+
by name and reused.
|
|
78
|
+
|
|
79
|
+
## Canonical visitor shape
|
|
80
|
+
|
|
81
|
+
Every identified visit is delivered to the trigger as a record with
|
|
82
|
+
these fields:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
{
|
|
86
|
+
linkedinUrl: string | null;
|
|
87
|
+
firstName: string | null;
|
|
88
|
+
lastName: string | null;
|
|
89
|
+
title: string | null;
|
|
90
|
+
companyName: string | null;
|
|
91
|
+
businessEmail: string | null;
|
|
92
|
+
website: string | null;
|
|
93
|
+
industry: string | null;
|
|
94
|
+
employeeCount: number | string | null;
|
|
95
|
+
estimatedRevenue: string | null;
|
|
96
|
+
city: string | null;
|
|
97
|
+
state: string | null;
|
|
98
|
+
zip: string | null;
|
|
99
|
+
seenAt: string | null;
|
|
100
|
+
referrer: string | null;
|
|
101
|
+
capturedUrl: string | null;
|
|
102
|
+
tags: string | null;
|
|
103
|
+
isRepeatVisit: boolean;
|
|
104
|
+
extra?: Record<string, unknown>; // optional extras (UTMs, seniority, etc.)
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Example chat flow
|
|
109
|
+
|
|
110
|
+
When a user says "I want to track who visits my site", do this:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const result = await integrations.websiteTracking.setupTracking({
|
|
114
|
+
domain: "acme.com"
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Then tell the user:
|
|
118
|
+
// 1. Paste this snippet (`result.script`) into the <head> of acme.com.
|
|
119
|
+
// 2. Visitor rows will land in the "Website Visitors" sheet within
|
|
120
|
+
// ~5 minutes of installing.
|
|
121
|
+
// 3. Each non-repeat visit costs 80 credits. This includes both
|
|
122
|
+
// full-person and company-only matches. Repeat visits from the
|
|
123
|
+
// same person are forwarded to the sheet but cost zero.
|
|
124
|
+
// 4. If credits run out, tracking pauses automatically. After topping
|
|
125
|
+
// up they'll need to ask you to re-enable it — call
|
|
126
|
+
// `integrations.websiteTracking.resumeTracking()` when that happens.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Pricing recap
|
|
130
|
+
|
|
131
|
+
Charged once per ping where `isRepeatVisit` is not `true`. This
|
|
132
|
+
includes both full-person identifications (`linkedinUrl` + `firstName`
|
|
133
|
+
populated) AND company-only matches (only `companyName` / `website`
|
|
134
|
+
populated).
|
|
135
|
+
|
|
136
|
+
Cost: **80 credits per non-repeat visit**. Repeat visits are still
|
|
137
|
+
forwarded to the sheet (so the user can analyze recurring traffic) but
|
|
138
|
+
cost zero.
|
|
139
|
+
|
|
140
|
+
If a customer wants to keep only full-person rows in their sheet, they
|
|
141
|
+
can filter in column code: `if (!v.linkedinUrl) return null;` skips
|
|
142
|
+
company-only rows before `addRows` writes them.
|
|
@@ -53,8 +53,30 @@ await ss.sheet("contacts").addRows([
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
- **`ss.sql(sql)`** — Execute EAV-SQL (same as `ctx.sql` but without needing to pass spreadsheetId).
|
|
56
|
+
- **`ss.describe()`** — Return the structural snapshot of the spreadsheet: its `id`, `name`, and an array of `sheets`, each with its `columns`. Use this to discover sheet/column names before issuing SQL — especially when attaching to a spreadsheet you didn't create.
|
|
56
57
|
- **`ss.sheet(name).addRows(rows)`** — Insert one or more rows into the named sheet. Accepts a single row object or an array. Missing columns are auto-created.
|
|
57
58
|
|
|
59
|
+
### Attaching to an existing spreadsheet
|
|
60
|
+
|
|
61
|
+
When the spreadsheet was created elsewhere (e.g. in the UI), use `describe()` to discover what's inside before running SQL:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const { spreadsheets } = await ctx.listSpreadsheets();
|
|
65
|
+
const target = spreadsheets.find((s) => s.name === "Find more paying customers V1")!;
|
|
66
|
+
|
|
67
|
+
const ss = ctx.spreadsheet(target.id);
|
|
68
|
+
const info = await ss.describe();
|
|
69
|
+
// info.sheets: [{ id, name, columns: [{ id, name }] }, ...]
|
|
70
|
+
|
|
71
|
+
for (const sheet of info.sheets) {
|
|
72
|
+
const cols = sheet.columns.map((c) => c.name).join(", ");
|
|
73
|
+
console.log(`${sheet.name}: ${cols}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Now you know the real sheet names; query safely
|
|
77
|
+
const result = await ss.sql(`SELECT * FROM "${info.sheets[0].name}" LIMIT 10`);
|
|
78
|
+
```
|
|
79
|
+
|
|
58
80
|
## EAV-SQL Reference
|
|
59
81
|
|
|
60
82
|
The `sql` method supports a subset of SQL mapped to Orange Slice's EAV storage:
|