@lacneu/wix-openclaw 0.5.1 → 0.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/CHANGELOG.md +79 -0
- package/README.md +89 -3
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +93 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/analytics.d.ts +124 -0
- package/dist/tools/analytics.js +655 -0
- package/dist/tools/analytics.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/openclaw.plugin.json +14 -1
- package/package.json +1 -1
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// Analytics — a discoverable query surface, plus one legacy endpoint.
|
|
2
|
+
//
|
|
3
|
+
// A THIRD KIND OF DANGER. The SEO writes destroyed the previous value of what
|
|
4
|
+
// they wrote; the accessibility scans could call an unchecked site clean. Here
|
|
5
|
+
// nothing is destroyed and nothing is scanned — what goes wrong is that the
|
|
6
|
+
// ANSWER IS QUIETLY SHORTER THAN THE QUESTION. Wix says so in the contract's
|
|
7
|
+
// own words, on the `fields` parameter:
|
|
8
|
+
//
|
|
9
|
+
// "Fields with unmet dependencies (see the `dependencies` property in the
|
|
10
|
+
// semantic model schema) are silently omitted from results."
|
|
11
|
+
//
|
|
12
|
+
// So a report can be missing an entire measure and look finished: nobody sees
|
|
13
|
+
// the column that never came back. Every answer here therefore names what was
|
|
14
|
+
// asked for and did not arrive, and says when a result was cut short.
|
|
15
|
+
//
|
|
16
|
+
// Two more of the same shape, both from the contract:
|
|
17
|
+
// - a query returns at most 1000 rows, so a truncated set must never read as
|
|
18
|
+
// a total;
|
|
19
|
+
// - the legacy Data API's `endDate` is EXCLUSIVE — `2024-01-01 → 2024-01-03`
|
|
20
|
+
// returns the 1st and the 2nd — so the days actually covered are stated,
|
|
21
|
+
// not left to be inferred.
|
|
22
|
+
//
|
|
23
|
+
// Contract sourced from the public Introductions and from the published SDK
|
|
24
|
+
// packages `@wix/auto_sdk_analytics-semantic-model_analytics-semantic-model`
|
|
25
|
+
// and `@wix/auto_sdk_analytics-data_analytics-data`, whose own host mappings
|
|
26
|
+
// give the REST prefixes verbatim. No path here is inferred from a convention.
|
|
27
|
+
import { Type } from "@sinclair/typebox";
|
|
28
|
+
import { defineWixTool } from "./_factory.js";
|
|
29
|
+
import { compactQuery } from "./_query.js";
|
|
30
|
+
import { WixApiError } from "../wix-client.js";
|
|
31
|
+
const SiteIdParam = Type.Optional(Type.String());
|
|
32
|
+
/** srcPath "/analytics/semantic-model/v3/semantic-models" → destPath "/v3/…" */
|
|
33
|
+
const MODELS = "/analytics/semantic-model/v3/semantic-models";
|
|
34
|
+
/** srcPath "/analytics-ng/v2" → destPath "/v2", protoPath "/v2/site-analytics/data" */
|
|
35
|
+
const DATA = "/analytics-ng/v2/site-analytics/data";
|
|
36
|
+
// TWO CONVENTIONS THIS MODULE REFUSES TO INVENT, and the evidence, because
|
|
37
|
+
// review has proposed both three times:
|
|
38
|
+
//
|
|
39
|
+
// - the semantic `interval` end bound. The full method reference describes
|
|
40
|
+
// `start` and `end` only as "an absolute instant"; the sole "inclusive" and
|
|
41
|
+
// "exclusive" wording on that page belongs to the RANGE_* FILTER
|
|
42
|
+
// conditions, not to the interval. And Wix's own example ends a period at
|
|
43
|
+
// `…T23:59:59.000Z` rather than at the next midnight, which is what an
|
|
44
|
+
// INCLUSIVE bound looks like. Advising "the next period's start" would
|
|
45
|
+
// silently drop a whole day if the bound is inclusive; advising "the last
|
|
46
|
+
// instant you mean" costs at most one second if it is exclusive. The tool
|
|
47
|
+
// states that the convention is undocumented and lets the caller choose.
|
|
48
|
+
// - the timezone applied when none is passed. The reference documents the
|
|
49
|
+
// defaults it does have — `paging.limit` is 50, `paging.offset` is 0,
|
|
50
|
+
// `formattingEnabled` is false, `totalsIncluded` is false — and names none
|
|
51
|
+
// for `timezone`. Reporting a guessed default would put day boundaries in
|
|
52
|
+
// a report that nothing supports.
|
|
53
|
+
//
|
|
54
|
+
// If either is ever documented, replace the wording — and only then.
|
|
55
|
+
// A BOUND THIS LOT DOES NOT RESOLVE, written down rather than guessed.
|
|
56
|
+
//
|
|
57
|
+
// These calls go out site-scoped, so the client sends `wix-site-id` and not
|
|
58
|
+
// `wix-account-id` — the two are mutually exclusive in this plugin, and
|
|
59
|
+
// `wix-site-id` is what the site whitelist is enforced on. Since the error is
|
|
60
|
+
// literally named `NO_ACCOUNT_IDENTITY`, it is possible that one or more of
|
|
61
|
+
// these endpoints wants the account header instead. Switching to it would
|
|
62
|
+
// silently remove the whitelist from this family, which is the plugin's main
|
|
63
|
+
// safety property, on nothing better than the wording of an error name — the
|
|
64
|
+
// reference itself calls the scope "Site Analytics" and classifies the error
|
|
65
|
+
// as a 401 about credentials, both of which point the other way.
|
|
66
|
+
//
|
|
67
|
+
// So: site-scoped, and if a live call with a valid key still answers
|
|
68
|
+
// NO_ACCOUNT_IDENTITY, that is the experiment that settles it. It has not been
|
|
69
|
+
// run.
|
|
70
|
+
/** An abort is not a failed read. Swallowed by a general `catch`, a cancelled
|
|
71
|
+
* call carried on and answered `ok` with a generic explanation — the caller
|
|
72
|
+
* had already given up, and got an answer that looked like a result. */
|
|
73
|
+
function rethrowIfAborted(err, signal) {
|
|
74
|
+
if (signal?.aborted === true)
|
|
75
|
+
throw err;
|
|
76
|
+
if (err instanceof Error && err.name === "AbortError")
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
/** Wix returns at most this many rows per query.
|
|
80
|
+
*
|
|
81
|
+
* From the Introduction, verbatim: "A single query can return up to 1,000
|
|
82
|
+
* rows." The request schema separately allows `paging.limit` up to 5000; the
|
|
83
|
+
* smaller, documented number is used, because over-asking would make the
|
|
84
|
+
* truncation check answer about a page size the service may not honour. */
|
|
85
|
+
const ROW_CAP = 1000;
|
|
86
|
+
/** What we ask for when the caller does not say.
|
|
87
|
+
*
|
|
88
|
+
* NOT `ROW_CAP`. The whole response is serialised into the model's context, and
|
|
89
|
+
* 1000 rows across up to 60 fields is tens of thousands of values — enough to
|
|
90
|
+
* swamp the context on an ordinary question. A conservative page keeps the tool
|
|
91
|
+
* usable while `rowsMayBeTruncated` still reports a full page exactly, because
|
|
92
|
+
* the limit is sent rather than assumed. A caller who wants more says so. */
|
|
93
|
+
const DEFAULT_LIMIT = 100;
|
|
94
|
+
/** The six measurement types the legacy Data API defines. Listed so a typo
|
|
95
|
+
* fails here, with the whole set in front of the caller, instead of coming
|
|
96
|
+
* back as an empty series that reads like "no activity". */
|
|
97
|
+
const MEASURES = [
|
|
98
|
+
"TOTAL_SESSIONS",
|
|
99
|
+
"TOTAL_UNIQUE_VISITORS",
|
|
100
|
+
"TOTAL_ORDERS",
|
|
101
|
+
"TOTAL_SALES",
|
|
102
|
+
"TOTAL_FORMS_SUBMITTED",
|
|
103
|
+
"CLICKS_TO_CONTACT",
|
|
104
|
+
];
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// The honesty layer.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
/** Which requested fields never came back?
|
|
109
|
+
*
|
|
110
|
+
* A DROPPED FIELD IS INVISIBLE IN THE RESPONSE. The rows simply lack the key,
|
|
111
|
+
* so a caller mapping over them reads `undefined` and, more often, never looks
|
|
112
|
+
* — the measure is missing from the report and nothing said so. */
|
|
113
|
+
function fieldsOf(row) {
|
|
114
|
+
// A ROW IS `{ fields: { name: value } }`, NOT a flat object. Reading the
|
|
115
|
+
// outer level found only the key `fields`, so every requested field looked
|
|
116
|
+
// absent on every real response. The flat form is still accepted, because
|
|
117
|
+
// being wrong in that direction costs nothing.
|
|
118
|
+
const inner = row.fields;
|
|
119
|
+
const source = inner !== null && typeof inner === "object" && !Array.isArray(inner)
|
|
120
|
+
? inner
|
|
121
|
+
: row;
|
|
122
|
+
return Object.keys(source);
|
|
123
|
+
}
|
|
124
|
+
/** Which requested fields never came back, and which cannot be judged.
|
|
125
|
+
*
|
|
126
|
+
* TWO DIFFERENT ANSWERS. Data rows establish presence for anything —
|
|
127
|
+
* dimensions included. The totals row does not: the contract defines it as the
|
|
128
|
+
* sum of the NUMERIC fields, so a dimension is not expected there and its
|
|
129
|
+
* absence from it proves nothing. With no data rows at all, a measure found in
|
|
130
|
+
* the totals did come back; everything else is unknown, not missing. */
|
|
131
|
+
function judgePresence(requested, rows, totals) {
|
|
132
|
+
const inTotals = new Set(totals !== undefined ? fieldsOf(totals) : []);
|
|
133
|
+
if (rows.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
missing: [],
|
|
136
|
+
undeterminable: requested.filter((f) => !inTotals.has(f)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ROWS ONLY, ON PURPOSE. `totals` is a SEPARATE aggregate over the whole
|
|
140
|
+
// result set; it does not supply the per-row column that was asked for. A
|
|
141
|
+
// measure present in the totals and absent from every row IS missing from
|
|
142
|
+
// this answer, and letting the totals cover for it put `fieldsNotReturned: []`
|
|
143
|
+
// on a response whose column was gone.
|
|
144
|
+
const present = new Set();
|
|
145
|
+
for (const row of rows)
|
|
146
|
+
for (const k of fieldsOf(row))
|
|
147
|
+
present.add(k);
|
|
148
|
+
return {
|
|
149
|
+
missing: requested.filter((f) => !present.has(f)),
|
|
150
|
+
undeterminable: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Every field name the model declares, across measures, dimensions and
|
|
154
|
+
* parameters. */
|
|
155
|
+
function declaredNames(model) {
|
|
156
|
+
const all = [
|
|
157
|
+
...(Array.isArray(model?.measures) ? model.measures : []),
|
|
158
|
+
...(Array.isArray(model?.dimensions) ? model.dimensions : []),
|
|
159
|
+
...(Array.isArray(model?.parameters) ? model.parameters : []),
|
|
160
|
+
];
|
|
161
|
+
return new Set(all.map((f) => f.name).filter((n) => typeof n === "string"));
|
|
162
|
+
}
|
|
163
|
+
/** Why a field was dropped, from the model's own schema.
|
|
164
|
+
*
|
|
165
|
+
* The rule is "at least one of `dependencies` must also be in the query", so
|
|
166
|
+
* the plugin can say exactly what to add — the caller should not have to
|
|
167
|
+
* re-derive it from a schema it already asked the plugin to read. */
|
|
168
|
+
function explainMissing(missing, fields, model, schemaRead) {
|
|
169
|
+
// NO SCHEMA, NO EXPLANATION. With the model unread, every field looked
|
|
170
|
+
// "not declared by the model" — a factual claim invented out of a failed
|
|
171
|
+
// second request. The report of what is missing stands on its own; the
|
|
172
|
+
// cause does not.
|
|
173
|
+
if (!schemaRead) {
|
|
174
|
+
return missing.map((field) => ({
|
|
175
|
+
field,
|
|
176
|
+
reason: "this field did not come back, and the model's schema could not be read — the cause is NOT established. Do not treat its absence as a zero.",
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
const all = [
|
|
180
|
+
...(Array.isArray(model?.measures) ? model.measures : []),
|
|
181
|
+
...(Array.isArray(model?.dimensions) ? model.dimensions : []),
|
|
182
|
+
...(Array.isArray(model?.parameters) ? model.parameters : []),
|
|
183
|
+
];
|
|
184
|
+
const asked = new Set(fields);
|
|
185
|
+
return missing.map((name) => {
|
|
186
|
+
const decl = all.find((f) => f.name === name);
|
|
187
|
+
if (decl === undefined) {
|
|
188
|
+
return {
|
|
189
|
+
field: name,
|
|
190
|
+
reason: "this field is not declared by the model — check the spelling against `wix_analytics_get_semantic_model`",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const deps = Array.isArray(decl.dependencies)
|
|
194
|
+
? decl.dependencies
|
|
195
|
+
: [];
|
|
196
|
+
if (deps.length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
field: name,
|
|
199
|
+
reason: "the model declares this field with no dependencies, so its absence is not explained by the dependency rule — treat this result as incomplete rather than as a zero",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (deps.some((d) => asked.has(d))) {
|
|
203
|
+
// NO `addOneOf` HERE. The dependency is already in the query, so handing
|
|
204
|
+
// the list back invites the same request a second time instead of
|
|
205
|
+
// treating the result as incomplete.
|
|
206
|
+
return {
|
|
207
|
+
field: name,
|
|
208
|
+
reason: "a dependency of this field WAS in the query, so the dependency rule does not explain its absence — treat this result as incomplete rather than as a zero",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
field: name,
|
|
213
|
+
reason: "none of this field's dependencies was in the query, so Wix omitted it silently",
|
|
214
|
+
addOneOf: deps,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** The documented application errors, and what each means for the caller. */
|
|
219
|
+
const ERROR_GUIDANCE = {
|
|
220
|
+
NO_ACCOUNT_IDENTITY: "Wix did not accept this identity for analytics. The reference classifies this as HTTP 401 / UNAUTHENTICATED: \"The caller isn't authenticated. Provide valid credentials and try again.\" So fix the CREDENTIALS first — the API key or the identity context it is sent with. Granting a permission does not fix an unauthenticated call. (Separately, all four methods require `SCOPE.DC-ANALYTICS-AND-REPORTS.READ-SITE-ANALYTICS`; that is a prerequisite to check once authentication works, not the meaning of this code.) It is NEVER evidence of a site with no traffic. Stop here and report it; do not retry with another range or another model.",
|
|
221
|
+
SEMANTIC_MODEL_NOT_FOUND: "No semantic model with that id. Call `wix_analytics_list_semantic_models` and use an id from it — the models available depend on the site and its installed apps.",
|
|
222
|
+
};
|
|
223
|
+
function explainError(err) {
|
|
224
|
+
if (!(err instanceof WixApiError))
|
|
225
|
+
return undefined;
|
|
226
|
+
const body = err.bodyPreview;
|
|
227
|
+
let structuredCode;
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(body);
|
|
230
|
+
const code = parsed.details?.applicationError?.code;
|
|
231
|
+
if (typeof code === "string")
|
|
232
|
+
structuredCode = code;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Unparseable body: fall through to the text checks below.
|
|
236
|
+
}
|
|
237
|
+
if (structuredCode !== undefined) {
|
|
238
|
+
// A STRUCTURED CODE THIS VERSION DOES NOT KNOW ENDS THE CLASSIFICATION,
|
|
239
|
+
// for the same reason as in the accessibility module: answering a new
|
|
240
|
+
// error as a known one returns `ok` with the wrong recovery.
|
|
241
|
+
const known = ERROR_GUIDANCE[structuredCode];
|
|
242
|
+
return known === undefined
|
|
243
|
+
? undefined
|
|
244
|
+
: { code: structuredCode, guidance: known };
|
|
245
|
+
}
|
|
246
|
+
// THE 62-DAY BOUND ARRIVES AS PROSE, not as a code — the reference quotes the
|
|
247
|
+
// message verbatim. Matching it is the only way to turn it into the one thing
|
|
248
|
+
// the caller needs to know: the data does not exist, the query was not wrong.
|
|
249
|
+
if (/do not have data for this start date/i.test(body)) {
|
|
250
|
+
return {
|
|
251
|
+
code: "START_DATE_BEYOND_RETENTION",
|
|
252
|
+
guidance: "Wix stores analytics data for 62 days only. A `startDate` more than 61 days before today is refused. This is a RETENTION limit, not an empty site: move the start date forward and say in the report that earlier data does not exist.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
for (const code of Object.keys(ERROR_GUIDANCE)) {
|
|
256
|
+
if (body.includes(code))
|
|
257
|
+
return { code, guidance: ERROR_GUIDANCE[code] };
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
export function buildAnalyticsTools(client) {
|
|
263
|
+
return [
|
|
264
|
+
defineWixTool({
|
|
265
|
+
name: "wix_analytics_list_semantic_models",
|
|
266
|
+
description: "ENTRY POINT for analytics. The semantic models this site exposes — " +
|
|
267
|
+
"each one an analytics subject area such as traffic or revenue. What " +
|
|
268
|
+
"exists depends on the site and its installed apps, so this list is " +
|
|
269
|
+
"READ, never assumed: there is no fixed set of models, measures or " +
|
|
270
|
+
"dimensions to hardcode. Take an `id` from here, inspect it with " +
|
|
271
|
+
"`wix_analytics_get_semantic_model`, then query it. Read-only.",
|
|
272
|
+
parameters: Type.Object({ siteId: SiteIdParam }),
|
|
273
|
+
run: async (params, c, signal) => {
|
|
274
|
+
try {
|
|
275
|
+
return await c.request("GET", MODELS, {
|
|
276
|
+
siteId: params.siteId,
|
|
277
|
+
signal,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
const known = explainError(err);
|
|
282
|
+
if (known === undefined)
|
|
283
|
+
throw err;
|
|
284
|
+
return { refused: known.code, ...known };
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
}, client),
|
|
288
|
+
defineWixTool({
|
|
289
|
+
name: "wix_analytics_get_semantic_model",
|
|
290
|
+
description: "One model's SCHEMA: its measures, dimensions and parameters, each " +
|
|
291
|
+
"with the exact `name` to put in a query, its type, whether it is " +
|
|
292
|
+
"sortable, its allowed values, and its `dependencies`. " +
|
|
293
|
+
"CALL THIS BEFORE QUERYING. A field whose dependencies are not also " +
|
|
294
|
+
"in the query is dropped from the results by Wix WITHOUT AN ERROR, " +
|
|
295
|
+
"so a query written without the schema can come back quietly short. " +
|
|
296
|
+
"This is also the only way to know whether this site exposes a " +
|
|
297
|
+
"per-page or per-URL dimension — that is a property of the site, not " +
|
|
298
|
+
"something to assume either way. Read-only.",
|
|
299
|
+
parameters: Type.Object({
|
|
300
|
+
siteId: SiteIdParam,
|
|
301
|
+
semanticModelId: Type.String({ minLength: 1 }),
|
|
302
|
+
}),
|
|
303
|
+
run: async (params, c, signal) => {
|
|
304
|
+
try {
|
|
305
|
+
return await c.request("GET", `${MODELS}/${encodeURIComponent(params.semanticModelId)}`, { siteId: params.siteId, signal });
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const known = explainError(err);
|
|
309
|
+
if (known === undefined)
|
|
310
|
+
throw err;
|
|
311
|
+
return { refused: known.code, ...known };
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
}, client),
|
|
315
|
+
defineWixTool({
|
|
316
|
+
name: "wix_analytics_query_semantic_model",
|
|
317
|
+
description: "Query one semantic model over a time interval. `fields` are the " +
|
|
318
|
+
"exact names from `wix_analytics_get_semantic_model` — a name that " +
|
|
319
|
+
"does not match returns nothing rather than an error. " +
|
|
320
|
+
"THE ANSWER CAN BE SHORTER THAN THE QUESTION: Wix silently omits a " +
|
|
321
|
+
"field whose dependencies are absent, so this tool compares what you " +
|
|
322
|
+
"asked for against what came back and reports every field that did " +
|
|
323
|
+
"not arrive, with the dependency to add. It also says when the " +
|
|
324
|
+
"1000-row cap was reached — a truncated set is never a total. " +
|
|
325
|
+
"`timezone` decides where each day starts; it is echoed back so a " +
|
|
326
|
+
"report can state it, and the contract does not document what Wix " +
|
|
327
|
+
"uses when it is omitted — pass it. The end bound's convention is " +
|
|
328
|
+
"not documented either: give the last instant you mean. " +
|
|
329
|
+
"UNIQUE MEASURES ARE NOT ADDITIVE across rows — ask for the period " +
|
|
330
|
+
"total rather than summing. Read-only.",
|
|
331
|
+
parameters: Type.Object({
|
|
332
|
+
siteId: SiteIdParam,
|
|
333
|
+
semanticModelId: Type.String({ minLength: 1 }),
|
|
334
|
+
fields: Type.Array(Type.String({ minLength: 1 }), {
|
|
335
|
+
minItems: 1,
|
|
336
|
+
maxItems: 60,
|
|
337
|
+
description: "Exact field names from the model's schema. Mixing measures and the dimensions they depend on is what makes a query return data.",
|
|
338
|
+
}),
|
|
339
|
+
start: Type.String({
|
|
340
|
+
minLength: 1,
|
|
341
|
+
description: "Interval start, ISO 8601.",
|
|
342
|
+
}),
|
|
343
|
+
end: Type.String({
|
|
344
|
+
minLength: 1,
|
|
345
|
+
description: "Interval end, ISO 8601. The contract does NOT say whether this bound is inclusive, and Wix's own reference example ends a period at `…T23:59:59.000Z` rather than at the next midnight — so give the last instant you mean rather than assuming either convention.",
|
|
346
|
+
}),
|
|
347
|
+
timezone: Type.Optional(Type.String({
|
|
348
|
+
description: "IANA timezone deciding where each day begins, e.g. `Europe/Paris`. Omitting it lets Wix choose, and day boundaries move.",
|
|
349
|
+
})),
|
|
350
|
+
limit: Type.Optional(Type.Integer({
|
|
351
|
+
minimum: 1,
|
|
352
|
+
maximum: ROW_CAP,
|
|
353
|
+
description: `Rows to ask for. Defaults to ${DEFAULT_LIMIT}: the whole response goes into the model's context, and Wix's own ceiling of ${ROW_CAP} rows across up to 60 fields would swamp it. Raise it deliberately.`,
|
|
354
|
+
})),
|
|
355
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
356
|
+
totalsIncluded: Type.Optional(Type.Boolean()),
|
|
357
|
+
sort: Type.Optional(Type.Object({
|
|
358
|
+
fieldName: Type.String({ minLength: 1 }),
|
|
359
|
+
order: Type.Optional(Type.Union([Type.Literal("ASC"), Type.Literal("DESC")])),
|
|
360
|
+
nullsLast: Type.Optional(Type.Boolean()),
|
|
361
|
+
}, {
|
|
362
|
+
description: "Sort by ONE field. The schema says per field whether it is `sortable` — check it before sorting, and note that a top-N answer needs both a sort and a `limit`.",
|
|
363
|
+
})),
|
|
364
|
+
filters: Type.Optional(Type.Array(
|
|
365
|
+
// THE CONTRACT'S OWN SHAPE: `field`, and `values` as a LIST —
|
|
366
|
+
// not `fieldName`/`value`. A range condition takes exactly two
|
|
367
|
+
// values, the start and the end.
|
|
368
|
+
Type.Object({
|
|
369
|
+
field: Type.String({
|
|
370
|
+
minLength: 1,
|
|
371
|
+
description: "Must match a measure or dimension name from the model's schema.",
|
|
372
|
+
}),
|
|
373
|
+
values: Type.Array(Type.String(), {
|
|
374
|
+
maxItems: 100,
|
|
375
|
+
description: "Values to compare against. A RANGE_* condition takes exactly two: the start and the end.",
|
|
376
|
+
}),
|
|
377
|
+
// CLOSED SETS, because they ARE closed. A plausible
|
|
378
|
+
// invention like `NOT_EQUAL` used to be accepted by the tool
|
|
379
|
+
// and rejected by Wix — the schema promising something the API
|
|
380
|
+
// does not honour. Negation is `prefix: "NOT"`, not a
|
|
381
|
+
// condition of its own.
|
|
382
|
+
condition: Type.Optional(Type.Union([
|
|
383
|
+
Type.Literal("EQUAL"),
|
|
384
|
+
Type.Literal("GREATER_THAN"),
|
|
385
|
+
Type.Literal("GREATER_THAN_OR_EQUAL"),
|
|
386
|
+
Type.Literal("LESS_THAN"),
|
|
387
|
+
Type.Literal("LESS_THAN_OR_EQUAL"),
|
|
388
|
+
Type.Literal("NULL"),
|
|
389
|
+
Type.Literal("EMPTY"),
|
|
390
|
+
Type.Literal("START_WITH"),
|
|
391
|
+
Type.Literal("END_WITH"),
|
|
392
|
+
Type.Literal("CONTAINS_ANY"),
|
|
393
|
+
Type.Literal("CONTAINS_ALL"),
|
|
394
|
+
Type.Literal("RANGE_II"),
|
|
395
|
+
Type.Literal("RANGE_IE"),
|
|
396
|
+
Type.Literal("RANGE_EI"),
|
|
397
|
+
Type.Literal("RANGE_EE"),
|
|
398
|
+
], {
|
|
399
|
+
description: "Default `EQUAL`. The model's schema lists which conditions each field supports; a RANGE_* condition takes exactly two `values`.",
|
|
400
|
+
})),
|
|
401
|
+
prefix: Type.Optional(Type.Union([Type.Literal("IS"), Type.Literal("NOT")], {
|
|
402
|
+
description: "Default `IS`. `NOT` negates the condition — there is no separate negated condition.",
|
|
403
|
+
})),
|
|
404
|
+
}), {
|
|
405
|
+
maxItems: 60,
|
|
406
|
+
description: "Filters, combined with AND. Without them a question like `the ten busiest days` cannot be expressed at all.",
|
|
407
|
+
})),
|
|
408
|
+
formattingEnabled: Type.Optional(Type.Boolean()),
|
|
409
|
+
}),
|
|
410
|
+
run: async (params, c, signal) => {
|
|
411
|
+
// A RANGE CONDITION TAKES EXACTLY TWO VALUES, says the contract.
|
|
412
|
+
// Accepting one or three passed this tool's own validation and then
|
|
413
|
+
// failed at Wix — the schema promising something it does not honour.
|
|
414
|
+
for (const f of params.filters ?? []) {
|
|
415
|
+
if (typeof f.condition === "string" &&
|
|
416
|
+
f.condition.startsWith("RANGE_") &&
|
|
417
|
+
f.values.length !== 2) {
|
|
418
|
+
throw new Error(`Refusing to query: filter on \`${f.field}\` uses ${f.condition} ` +
|
|
419
|
+
`with ${f.values.length} value(s). A RANGE condition takes ` +
|
|
420
|
+
"exactly two: the start and the end of the range.");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
let resp = null;
|
|
424
|
+
try {
|
|
425
|
+
resp = (await c.request("POST", `${MODELS}/query-data`, {
|
|
426
|
+
siteId: params.siteId,
|
|
427
|
+
signal,
|
|
428
|
+
body: {
|
|
429
|
+
semanticModelId: params.semanticModelId,
|
|
430
|
+
interval: {
|
|
431
|
+
start: params.start,
|
|
432
|
+
end: params.end,
|
|
433
|
+
...(params.timezone !== undefined
|
|
434
|
+
? { timezone: params.timezone }
|
|
435
|
+
: {}),
|
|
436
|
+
},
|
|
437
|
+
fields: params.fields,
|
|
438
|
+
// THE LIMIT IS ALWAYS SENT. The documented default is
|
|
439
|
+
// `paging.limit = 50`, so omitting it returned the first 50
|
|
440
|
+
// rows of a larger answer while truncation was judged
|
|
441
|
+
// against 1000 — a first page reported as the whole thing.
|
|
442
|
+
// Sent explicitly, "did it hit the limit" is exact.
|
|
443
|
+
paging: {
|
|
444
|
+
limit: params.limit ?? DEFAULT_LIMIT,
|
|
445
|
+
...(params.offset !== undefined
|
|
446
|
+
? { offset: params.offset }
|
|
447
|
+
: {}),
|
|
448
|
+
},
|
|
449
|
+
...(params.totalsIncluded !== undefined
|
|
450
|
+
? { totalsIncluded: params.totalsIncluded }
|
|
451
|
+
: {}),
|
|
452
|
+
...(params.sort !== undefined ? { sort: params.sort } : {}),
|
|
453
|
+
...(params.filters !== undefined
|
|
454
|
+
? { filters: params.filters }
|
|
455
|
+
: {}),
|
|
456
|
+
...(params.formattingEnabled !== undefined
|
|
457
|
+
? { formattingEnabled: params.formattingEnabled }
|
|
458
|
+
: {}),
|
|
459
|
+
},
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
const known = explainError(err);
|
|
464
|
+
if (known === undefined)
|
|
465
|
+
throw err;
|
|
466
|
+
return { refused: known.code, ...known };
|
|
467
|
+
}
|
|
468
|
+
const rows = Array.isArray(resp?.results)
|
|
469
|
+
? resp.results
|
|
470
|
+
: [];
|
|
471
|
+
const totalsRow = resp?.totals !== null &&
|
|
472
|
+
typeof resp?.totals === "object" &&
|
|
473
|
+
!Array.isArray(resp.totals)
|
|
474
|
+
? resp.totals
|
|
475
|
+
: undefined;
|
|
476
|
+
const { missing, undeterminable } = judgePresence(params.fields, rows, totalsRow);
|
|
477
|
+
// A DIMENSION IS NOT EXPECTED IN THE TOTALS ROW, so with no data
|
|
478
|
+
// rows its absence proves nothing — that is "unknown", not
|
|
479
|
+
// "missing", and an invalid field name looks identical.
|
|
480
|
+
const noRows = undeterminable.length > 0;
|
|
481
|
+
// THE SCHEMA IS READ ONLY WHEN IT CAN CHANGE THE ANSWER: something
|
|
482
|
+
// is missing, or nothing came back at all. The happy path pays
|
|
483
|
+
// nothing.
|
|
484
|
+
let explained = [];
|
|
485
|
+
let undeclared = [];
|
|
486
|
+
let schemaRead = false;
|
|
487
|
+
if (missing.length > 0 || noRows) {
|
|
488
|
+
let model;
|
|
489
|
+
try {
|
|
490
|
+
const got = (await c.request("GET", `${MODELS}/${encodeURIComponent(params.semanticModelId)}`, { siteId: params.siteId, signal }));
|
|
491
|
+
model = (got?.semanticModel ?? got ?? undefined);
|
|
492
|
+
schemaRead = model !== undefined;
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
rethrowIfAborted(err, signal);
|
|
496
|
+
// The explanation is a courtesy; the REPORT of what is missing
|
|
497
|
+
// is not, and it stands without the schema.
|
|
498
|
+
}
|
|
499
|
+
if (schemaRead) {
|
|
500
|
+
const declared = declaredNames(model);
|
|
501
|
+
undeclared = params.fields.filter((f) => !declared.has(f));
|
|
502
|
+
}
|
|
503
|
+
// THE DEPENDENCY RULE IS A FACT ABOUT THE SCHEMA, not about the
|
|
504
|
+
// data — so it explains just as much when NOTHING came back. An
|
|
505
|
+
// empty page used to answer only "no data returned" for a field
|
|
506
|
+
// the schema already proved could not have been returned, hiding
|
|
507
|
+
// the very mistake this layer exists to name.
|
|
508
|
+
const toExplain = missing.length > 0 ? missing : undeterminable;
|
|
509
|
+
if (toExplain.length > 0 && schemaRead) {
|
|
510
|
+
explained = explainMissing(toExplain, params.fields, model, schemaRead).filter((e) =>
|
|
511
|
+
// On an empty page, keep only the reasons that do NOT depend
|
|
512
|
+
// on the data: a dependency that was absent, or a name the
|
|
513
|
+
// model does not declare. "Incomplete result" says nothing
|
|
514
|
+
// there and would read as a finding.
|
|
515
|
+
missing.length > 0 ||
|
|
516
|
+
e.addOneOf !== undefined ||
|
|
517
|
+
/not declared by the model/.test(e.reason));
|
|
518
|
+
}
|
|
519
|
+
else if (missing.length > 0) {
|
|
520
|
+
explained = explainMissing(missing, params.fields, model, schemaRead);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const sentLimit = params.limit ?? DEFAULT_LIMIT;
|
|
524
|
+
// A FULL PAGE IS NOT PROOF OF TRUNCATION. Exactly `limit` rows
|
|
525
|
+
// may be the entire answer, and `pagingMetadata` carries no total to
|
|
526
|
+
// settle it — so this says MAYBE, and says how to find out. Claiming
|
|
527
|
+
// "cut short" for a complete result is the same false certainty this
|
|
528
|
+
// module exists to avoid, pointed the other way.
|
|
529
|
+
const maybeTruncated = rows.length >= sentLimit;
|
|
530
|
+
// AN OFFSET ALREADY OMITTED EVERYTHING BEFORE IT. A short last page
|
|
531
|
+
// reported nothing partial, so a single call on page three read like
|
|
532
|
+
// the whole interval.
|
|
533
|
+
const skipped = params.offset ?? 0;
|
|
534
|
+
return {
|
|
535
|
+
results: rows,
|
|
536
|
+
pagingMetadata: resp?.pagingMetadata,
|
|
537
|
+
interval: {
|
|
538
|
+
start: params.start,
|
|
539
|
+
end: params.end,
|
|
540
|
+
timezone: params.timezone ??
|
|
541
|
+
"NOT SPECIFIED — the contract does not document the default, so the day boundaries behind these numbers are unknown. Pass `timezone` before reporting anything per-day.",
|
|
542
|
+
},
|
|
543
|
+
// COUNTED AND NAMED, not left in prose. A measure that never came
|
|
544
|
+
// back is invisible in the rows themselves.
|
|
545
|
+
fieldsRequested: params.fields.length,
|
|
546
|
+
fieldsNotReturned: missing,
|
|
547
|
+
...(explained.length > 0 ? { whyFieldsAreMissing: explained } : {}),
|
|
548
|
+
...(noRows
|
|
549
|
+
? {
|
|
550
|
+
fieldPresenceUndeterminable: undeterminable,
|
|
551
|
+
noRowsCaveat: "NO DATA ROWS CAME BACK, so this response cannot say whether these fields would have arrived — an invalid field name gives the same empty result as a genuinely quiet period. Report it as 'no data returned', not as zeros.",
|
|
552
|
+
}
|
|
553
|
+
: {}),
|
|
554
|
+
...(undeclared.length > 0
|
|
555
|
+
? {
|
|
556
|
+
fieldsNotDeclaredByTheModel: undeclared,
|
|
557
|
+
fieldsNotDeclaredCaveat: "The model does not declare these names. Check them against `wix_analytics_get_semantic_model` — a name that does not match returns nothing rather than an error.",
|
|
558
|
+
}
|
|
559
|
+
: {}),
|
|
560
|
+
rowsMayBeTruncated: maybeTruncated,
|
|
561
|
+
rowsRequestedAtMost: sentLimit,
|
|
562
|
+
rowsSkippedByOffset: skipped,
|
|
563
|
+
...(skipped > 0
|
|
564
|
+
? {
|
|
565
|
+
offsetCaveat: `This page starts at offset ${skipped}, so it OMITS every row before it by construction. It is a slice, not the interval — say so, or page from 0.`,
|
|
566
|
+
}
|
|
567
|
+
: {}),
|
|
568
|
+
// NOT CONDITIONAL ON THE PAGE BEING FULL. Whether a measure can be
|
|
569
|
+
// added up is a property of the measure, and seven daily rows are
|
|
570
|
+
// just as easy to sum wrongly as a thousand.
|
|
571
|
+
additivity: "Before summing anything: a UNIQUE measure such as unique visitors counts the same person once per row, so adding rows overstates it — and `totalsIncluded` does not rescue it either, since `totals` is defined as the SUM of the numeric fields. The only meaningful total for a unique measure is a query for it over the whole interval WITHOUT a grouping dimension.",
|
|
572
|
+
...(maybeTruncated
|
|
573
|
+
? {
|
|
574
|
+
caveat: "This result fills the page exactly, so it MAY be the first page of more — nothing in the response settles it. Page with `offset` until fewer rows come back before drawing any conclusion about the whole period. And do NOT add the rows up: a UNIQUE measure such as unique visitors counts the same person once per row, so summing rows overstates it however many pages you fetch. See `additivity` before summing.",
|
|
575
|
+
}
|
|
576
|
+
: {}),
|
|
577
|
+
// An absent `totals` is not a zero: it is simply not asked for.
|
|
578
|
+
// A REQUESTED TOTAL THAT DID NOT COME BACK MUST STILL SAY SO.
|
|
579
|
+
// Left as `undefined`, `JSON.stringify` dropped the key entirely —
|
|
580
|
+
// so the one guarantee that matters here, "an absent total is not
|
|
581
|
+
// a zero", vanished exactly when the total was actually absent.
|
|
582
|
+
totals: params.totalsIncluded !== true
|
|
583
|
+
? "not requested — pass `totalsIncluded: true` to get it; its absence is not a zero"
|
|
584
|
+
: totalsRow !== undefined
|
|
585
|
+
? resp?.totals
|
|
586
|
+
: "REQUESTED BUT NOT RETURNED — Wix omitted the totals row. That is not a zero: query the period directly, or check that the fields you asked for can be totalled at all.",
|
|
587
|
+
};
|
|
588
|
+
},
|
|
589
|
+
}, client),
|
|
590
|
+
defineWixTool({
|
|
591
|
+
name: "wix_analytics_get_data",
|
|
592
|
+
description: "The legacy site-wide counters: sessions, unique visitors, orders, " +
|
|
593
|
+
"sales, forms submitted, clicks to contact. SITE LEVEL ONLY — there " +
|
|
594
|
+
"is no per-page breakdown here; for anything richer use the semantic " +
|
|
595
|
+
"models. " +
|
|
596
|
+
"TWO BOUNDS THAT CHANGE WHAT THE NUMBERS MEAN. Wix keeps 62 days: a " +
|
|
597
|
+
"`startDate` more than 61 days back is refused, and that is a " +
|
|
598
|
+
"retention limit, not an empty site. And `endDate` is EXCLUSIVE — " +
|
|
599
|
+
"`2024-01-01` to `2024-01-03` covers the 1st and the 2nd — so this " +
|
|
600
|
+
"tool reports the days it actually covered. Read-only.",
|
|
601
|
+
parameters: Type.Object({
|
|
602
|
+
siteId: SiteIdParam,
|
|
603
|
+
startDate: Type.String({
|
|
604
|
+
minLength: 1,
|
|
605
|
+
description: "ISO date, e.g. `2026-08-01`. At most 61 days back.",
|
|
606
|
+
}),
|
|
607
|
+
endDate: Type.String({
|
|
608
|
+
minLength: 1,
|
|
609
|
+
description: "ISO date, EXCLUSIVE — the returned data stops the day before.",
|
|
610
|
+
}),
|
|
611
|
+
measurementTypes: Type.Array(Type.String({ minLength: 1 }), {
|
|
612
|
+
minItems: 1,
|
|
613
|
+
description: `One or more of: ${MEASURES.join(", ")}.`,
|
|
614
|
+
}),
|
|
615
|
+
timeZone: Type.Optional(Type.String()),
|
|
616
|
+
}),
|
|
617
|
+
run: async (params, c, signal) => {
|
|
618
|
+
// A TYPO WOULD COME BACK AS AN EMPTY SERIES, which reads like "no
|
|
619
|
+
// activity" rather than "you asked for something that does not
|
|
620
|
+
// exist". The whole set is put in front of the caller instead.
|
|
621
|
+
const unknown = params.measurementTypes.filter((m) => !MEASURES.includes(m));
|
|
622
|
+
if (unknown.length > 0) {
|
|
623
|
+
throw new Error(`Refusing to query: unknown measurement type(s) ${unknown.join(", ")}. ` +
|
|
624
|
+
`This endpoint defines exactly six: ${MEASURES.join(", ")}.`);
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const resp = (await c.request("GET", DATA, {
|
|
628
|
+
siteId: params.siteId,
|
|
629
|
+
signal,
|
|
630
|
+
query: compactQuery({
|
|
631
|
+
"dateRange.startDate": params.startDate,
|
|
632
|
+
"dateRange.endDate": params.endDate,
|
|
633
|
+
timeZone: params.timeZone,
|
|
634
|
+
measurementTypes: params.measurementTypes,
|
|
635
|
+
}),
|
|
636
|
+
}));
|
|
637
|
+
return {
|
|
638
|
+
data: resp?.data,
|
|
639
|
+
// SAID, NOT INFERRED. The exclusive end is an off-by-one that
|
|
640
|
+
// silently drops the last day a caller thought it asked for.
|
|
641
|
+
covers: `${params.startDate} up to but NOT including ${params.endDate}`,
|
|
642
|
+
siteLevelOnly: true,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
const known = explainError(err);
|
|
647
|
+
if (known === undefined)
|
|
648
|
+
throw err;
|
|
649
|
+
return { refused: known.code, ...known };
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
}, client),
|
|
653
|
+
];
|
|
654
|
+
}
|
|
655
|
+
//# sourceMappingURL=analytics.js.map
|