@recapi/client 0.3.0 → 0.5.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/README.md +72 -10
- package/index.d.ts +11 -2
- package/package.json +1 -1
- package/src/client.js +27 -12
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ are, gzipped, and browsers cache them for a year:
|
|
|
16
16
|
|
|
17
17
|
```html
|
|
18
18
|
<script type="module">
|
|
19
|
-
import { createClient } from 'https://api.example.com/sdk/0.
|
|
19
|
+
import { createClient } from 'https://api.example.com/sdk/0.5.0/index.js';
|
|
20
20
|
|
|
21
21
|
const recapi = createClient({ apiKey: 'rk_pub_…', baseUrl: 'https://api.example.com' });
|
|
22
22
|
recapi.track('item-42', 'view'); // and the calls of the quick start below
|
|
@@ -64,7 +64,7 @@ const observer = new IntersectionObserver(async (entries) => {
|
|
|
64
64
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
65
65
|
observer.disconnect();
|
|
66
66
|
const { items } = await recapi.recommend({ count: 8, tagsAny: ['sports'] });
|
|
67
|
-
render(slot, items); // [{ itemId, score, slot, metadata }]
|
|
67
|
+
render(slot, items); // [{ itemId, score, slot, why, metadata }]
|
|
68
68
|
}, { rootMargin: '0px 0px 400px 0px' });
|
|
69
69
|
observer.observe(slot);
|
|
70
70
|
|
|
@@ -103,7 +103,15 @@ in two places, both shown with your address in the dashboard's quick start:
|
|
|
103
103
|
so they are unchanged. Each card shows the picture (resized by Shopify's
|
|
104
104
|
CDN with `?width=400`), the title and `price_min` formatted with
|
|
105
105
|
`Intl.NumberFormat` in `Shopify.currency.active` and the page's `lang`
|
|
106
|
-
(no price if either is missing).
|
|
106
|
+
(no price if either is missing). An item whose
|
|
107
|
+
[metadata](../../docs/api.md#item-metadata) has a `name` and an http(s)
|
|
108
|
+
`url` skips that lookup: its card is drawn from `name`, `url`, `picture`
|
|
109
|
+
(http(s) only) and `price` (text shown as it is, or a number in the
|
|
110
|
+
shop's currency, `{{ shop.currency }}`, shown only to visitors browsing in
|
|
111
|
+
it; others get Shopify's converted price from the lookup). A row whose
|
|
112
|
+
items all carry metadata then costs one request instead of seven. The
|
|
113
|
+
snippet can't send metadata: the shop sends it with `PUT /v1/items`, and
|
|
114
|
+
marks a sold-out product inactive there, since no lookup checks it. A default style makes them a product row
|
|
107
115
|
in the theme's colours and fonts; all its selectors sit inside
|
|
108
116
|
`:where(#recapi-recommendations …)`, which has no specificity, so any of
|
|
109
117
|
the theme's rules overrides it. It follows the shop's cookie banner through
|
|
@@ -224,7 +232,11 @@ shortcode shows none unless given one: `[recapi heading="You may also like"]`.
|
|
|
224
232
|
The widget fills it like the Shopify snippet's, once it nears the screen,
|
|
225
233
|
loading the 6 recommended products in one request to WooCommerce's public Store API
|
|
226
234
|
(`/wc/store/v1/products?include=…`) and skipping those not purchasable or
|
|
227
|
-
out of stock.
|
|
235
|
+
out of stock. Items whose [metadata](../../docs/api.md#item-metadata)
|
|
236
|
+
has a `name` and an http(s) `url` are left out of that request and drawn
|
|
237
|
+
from it, as in the Shopify snippet (a number `price` is in the shop's
|
|
238
|
+
currency, `woocommerce_currency`, and shown only while the visitor browses
|
|
239
|
+
in it), so a row whose items all carry metadata needs no Store API call. Consent comes from the
|
|
228
240
|
[WP Consent API](https://wordpress.org/plugins/wp-consent-api/)
|
|
229
241
|
(`wp_has_consent('statistics')`, `wp_listen_for_consent_change`), which
|
|
230
242
|
banner plugins feed and which reports allowed where no banner manages
|
|
@@ -250,6 +262,17 @@ stands in for WordPress, run by `scripts/php.sh`) and runs the printed
|
|
|
250
262
|
script against the SDK. The quick start fills in the current version and
|
|
251
263
|
your address (`jssdk.WooCommerce`).
|
|
252
264
|
|
|
265
|
+
### Magento
|
|
266
|
+
|
|
267
|
+
The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
|
|
268
|
+
the SDK on every storefront page for views and add-to-carts, and sends
|
|
269
|
+
orders from the server with the PHP library. With consent, its script
|
|
270
|
+
mirrors the visitor's id and clicked recommendations into a first-party
|
|
271
|
+
cookie so the server's purchases are credited like the SDK's own. On product
|
|
272
|
+
pages it fills recapi's row, drawing each card from the item's `metadata`
|
|
273
|
+
or, without it, from the store; the row is printed with the store's own
|
|
274
|
+
products, which stay while recapi has none.
|
|
275
|
+
|
|
253
276
|
### Node
|
|
254
277
|
|
|
255
278
|
```js
|
|
@@ -269,6 +292,23 @@ await recapi.destroy(); // flushes queued events before exit
|
|
|
269
292
|
Timers do not keep a Node process alive. Call `await client.flush()` or
|
|
270
293
|
`await client.destroy()` before exiting, or queued events are lost.
|
|
271
294
|
|
|
295
|
+
### Showing why an item is listed
|
|
296
|
+
|
|
297
|
+
Every item carries `why`, a short [reason code](../../docs/api.md#reason-codes)
|
|
298
|
+
that names no item of the visitor's history, so any page can show it. Pick
|
|
299
|
+
your own words, and show no label for a code you don't know (new ones may
|
|
300
|
+
come):
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
const LABELS = {
|
|
304
|
+
for_you: 'Picked for you', similar: 'Similar items', next: 'Up next',
|
|
305
|
+
trending: 'Trending now', popular: 'Popular', new: 'New',
|
|
306
|
+
};
|
|
307
|
+
for (const item of items) {
|
|
308
|
+
const label = LABELS[item.why]; // undefined for explore, more and future codes: no label
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
272
312
|
## API
|
|
273
313
|
|
|
274
314
|
### `createClient(options)`
|
|
@@ -285,7 +325,7 @@ Timers do not keep a Node process alive. Call `await client.flush()` or
|
|
|
285
325
|
| `timeoutMs` | `5000` | per-request timeout; `recommend()` waits 1.5 s at most |
|
|
286
326
|
| `historyConfig` | spec defaults | `{ halfLifeSeconds, topK, recentSize }`; must match the server |
|
|
287
327
|
| `consent` | `true` | storage consent, see [Consent](#consent-and-eprivacy) |
|
|
288
|
-
| `respectDoNotTrack` | `
|
|
328
|
+
| `respectDoNotTrack` | `true` | when DNT or Global Privacy Control is on: store nothing, send no events, see [Do Not Track](#do-not-track) |
|
|
289
329
|
| `onError` | – | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
|
|
290
330
|
| `now`, `fetch`, `sleep` | `Date.now`, global `fetch`, `setTimeout` | for tests |
|
|
291
331
|
|
|
@@ -308,17 +348,17 @@ Timers do not keep a Node process alive. Call `await client.flush()` or
|
|
|
308
348
|
control characters. A stored history entry whose id breaks the rule,
|
|
309
349
|
left by an earlier SDK version, is dropped when the page loads.
|
|
310
350
|
- **`recommend({ count, tagsAny, tagsAll, exclude, contextItemId, placement, discovery })`**
|
|
311
|
-
resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot, metadata }] }`.
|
|
351
|
+
resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot, why, metadata }] }`.
|
|
312
352
|
`metadata` is the object you stored with the item
|
|
313
353
|
([Item metadata](../../docs/api.md#item-metadata): name, picture, link,
|
|
314
354
|
price…), absent when it has none, so you can render the list without a
|
|
315
|
-
lookup.
|
|
355
|
+
lookup. `why` is the item's [reason code](#showing-why-an-item-is-listed).
|
|
316
356
|
It never retries and waits at most 1.5 s (less if `timeoutMs` is lower):
|
|
317
357
|
a late recommendation is worthless. See [Fallback](#fallback) for what
|
|
318
358
|
happens when the call fails. The user's rejected items (the newest 200)
|
|
319
359
|
are added to `exclude`, after yours, up to the API's limit of 500. The SDK does not send `explain`, which needs
|
|
320
360
|
a secret key: call the API from your backend for that.
|
|
321
|
-
- **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score, metadata }] }`.
|
|
361
|
+
- **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score, why, metadata }] }`.
|
|
322
362
|
A public key goes in `?key=`, so the response can be cached by a CDN and
|
|
323
363
|
the request needs no CORS preflight. A secret key (Node) goes in the
|
|
324
364
|
`Authorization` header, because the API refuses a secret key in a URL.
|
|
@@ -525,8 +565,21 @@ if (rec !== null) {
|
|
|
525
565
|
Events are still sent, without `anon_id`. Don't call `track()` if you
|
|
526
566
|
need no network at all. Withdrawing consent deletes the stored data.
|
|
527
567
|
Granting it merges the in-memory history into storage.
|
|
528
|
-
|
|
529
|
-
|
|
568
|
+
|
|
569
|
+
### Do Not Track
|
|
570
|
+
|
|
571
|
+
When the browser sends Do Not Track (`navigator.doNotTrack === '1'`) or
|
|
572
|
+
Global Privacy Control (`navigator.globalPrivacyControl === true`), the
|
|
573
|
+
SDK follows nobody: it reads and writes no storage, `track()` and
|
|
574
|
+
`identify()` do nothing, and `setConsent(true)` doesn't lift it (the
|
|
575
|
+
browser's setting is the visitor's standing answer). `recommend()` and
|
|
576
|
+
`popular()` still work: the request carries no visitor id and no history,
|
|
577
|
+
so the list is popular, trending or similar items (with `contextItemId`).
|
|
578
|
+
The Shopify and WooCommerce snippets and Magento's storefront script
|
|
579
|
+
build on the SDK and behave the same (Magento's order observer runs on the
|
|
580
|
+
server and still sends the purchase). A site with its own legal basis for tracking those visitors
|
|
581
|
+
passes `respectDoNotTrack: false` and gets the behaviour of 0.4.0 and
|
|
582
|
+
earlier.
|
|
530
583
|
|
|
531
584
|
## Custom storage
|
|
532
585
|
|
|
@@ -560,6 +613,15 @@ minifier, to keep zero dev dependencies.
|
|
|
560
613
|
|
|
561
614
|
## Changes
|
|
562
615
|
|
|
616
|
+
- 2026-11-01: version 0.5.0. Do Not Track and Global Privacy Control are
|
|
617
|
+
respected by default: the SDK then stores nothing and sends no events or
|
|
618
|
+
visitor ids, and still asks for recommendations
|
|
619
|
+
([Do Not Track](#do-not-track)). Before, `respectDoNotTrack` was off by
|
|
620
|
+
default and only stopped storage. Snippets pasted with an older version
|
|
621
|
+
get it too: older versions' paths redirect to the current one.
|
|
622
|
+
- 2026-10-31: version 0.4.0. Recommended and popular items carry `why`,
|
|
623
|
+
a reason code you can turn into a label for visitors
|
|
624
|
+
([Showing why an item is listed](#showing-why-an-item-is-listed)).
|
|
563
625
|
- 2026-10-31: version 0.3.0. Recommended and popular items carry the
|
|
564
626
|
item's `metadata` (name, picture, link, price… stored with
|
|
565
627
|
`PUT /v1/items`) when it has some, fallback lists included
|
package/index.d.ts
CHANGED
|
@@ -78,7 +78,10 @@ export interface ClientOptions {
|
|
|
78
78
|
historyConfig?: Partial<HistoryConfig>;
|
|
79
79
|
/** Initial storage consent, default `true`. */
|
|
80
80
|
consent?: boolean;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Default `true`: when Do Not Track / Global Privacy Control is on, store
|
|
83
|
+
* nothing, send no events and no visitor ids; recommendations still work.
|
|
84
|
+
*/
|
|
82
85
|
respectDoNotTrack?: boolean;
|
|
83
86
|
/** Receives errors from background work (flushes, storage) and those `recommend()` fell back from. */
|
|
84
87
|
onError?: (error: unknown) => void;
|
|
@@ -99,6 +102,10 @@ export interface RecommendParams {
|
|
|
99
102
|
discovery?: number;
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/** An item's reason code: map the ones you want to your own label, show none for an unknown one. */
|
|
106
|
+
export type ReasonCode =
|
|
107
|
+
| 'for_you' | 'similar' | 'next' | 'trending' | 'popular' | 'new' | 'explore' | 'more' | (string & {});
|
|
108
|
+
|
|
102
109
|
/** The object stored with the item (`PUT /v1/items`, `metadata`): name, picture, link, price... */
|
|
103
110
|
export type ItemMetadata = Record<string, unknown>;
|
|
104
111
|
|
|
@@ -106,6 +113,8 @@ export interface RecommendedItem {
|
|
|
106
113
|
itemId: string;
|
|
107
114
|
score: number;
|
|
108
115
|
slot: 'exploit' | 'explore' | 'backfill' | string;
|
|
116
|
+
/** Why the item is listed, to label it for visitors (docs/api.md, "Reason codes"). New codes may be added. */
|
|
117
|
+
why?: ReasonCode;
|
|
109
118
|
/** Absent when the item has no metadata. */
|
|
110
119
|
metadata?: ItemMetadata;
|
|
111
120
|
}
|
|
@@ -130,7 +139,7 @@ export interface PopularParams {
|
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
export interface PopularResult {
|
|
133
|
-
items: Array<{ itemId: string; score: number; metadata?: ItemMetadata }>;
|
|
142
|
+
items: Array<{ itemId: string; score: number; why?: ReasonCode; metadata?: ItemMetadata }>;
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
/**
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -80,8 +80,9 @@ const PUBLIC_KEY_PREFIX = 'rk_pub_';
|
|
|
80
80
|
* @property {number} [timeoutMs] Per-request timeout.
|
|
81
81
|
* @property {Partial<HistoryConfig>} [historyConfig]
|
|
82
82
|
* @property {boolean} [consent] Initial storage consent (default `true`).
|
|
83
|
-
* @property {boolean} [respectDoNotTrack]
|
|
84
|
-
*
|
|
83
|
+
* @property {boolean} [respectDoNotTrack] When the browser sends Do Not
|
|
84
|
+
* Track or Global Privacy Control (default `true`): store nothing, send no
|
|
85
|
+
* events and no visitor ids; recommendations are still asked for.
|
|
85
86
|
* @property {(error: unknown) => void} [onError] Receives background errors,
|
|
86
87
|
* and the errors `recommend()` fell back from.
|
|
87
88
|
* @property {() => number} [now] Clock in unix ms.
|
|
@@ -127,8 +128,14 @@ export function createClient(options) {
|
|
|
127
128
|
report
|
|
128
129
|
);
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
// DNT and GPC ask sites not to follow the visitor, and the homepage
|
|
132
|
+
// promises our script listens: such a page sends recapi nothing about them
|
|
133
|
+
// and keeps nothing on the device, so its lists are popular or similar
|
|
134
|
+
// items only. A banner's consent doesn't lift it: the browser's answer is
|
|
135
|
+
// the visitor's standing one. `respectDoNotTrack: false` is for a site with
|
|
136
|
+
// its own legal basis.
|
|
137
|
+
const quiet = (options.respectDoNotTrack ?? true) && doNotTrackEnabled();
|
|
138
|
+
let consent = !quiet && (options.consent ?? true);
|
|
132
139
|
|
|
133
140
|
/** @type {HistoryState} */
|
|
134
141
|
let history = emptyHistory();
|
|
@@ -400,6 +407,7 @@ export function createClient(options) {
|
|
|
400
407
|
* `reject` is -10). A negative level is an explicit rejection ("not
|
|
401
408
|
* interested"): the item is never recommended to this user again until
|
|
402
409
|
* a positive event on it. Don't send skips or bounces as rejections.
|
|
410
|
+
* Does nothing under Do Not Track (see `respectDoNotTrack`).
|
|
403
411
|
* @param {{ recommendationId?: string | null }} [options] Overrides the
|
|
404
412
|
* automatic attribution. `null` (a fallback list's id) is no override.
|
|
405
413
|
*/
|
|
@@ -415,6 +423,7 @@ export function createClient(options) {
|
|
|
415
423
|
"level must be an integer between -10 and 10, or 'view', 'cart', 'purchase' or 'reject'"
|
|
416
424
|
);
|
|
417
425
|
}
|
|
426
|
+
if (quiet) return;
|
|
418
427
|
const t = now();
|
|
419
428
|
let attributed = recommendationId;
|
|
420
429
|
mutate(() => {
|
|
@@ -448,7 +457,7 @@ export function createClient(options) {
|
|
|
448
457
|
* @param {{ count?: number, tagsAny?: string[], tagsAll?: string[], exclude?: string[],
|
|
449
458
|
* contextItemId?: string, placement?: string, discovery?: number }} [params]
|
|
450
459
|
* @returns {Promise<{ recommendationId: string | null, fallback: boolean,
|
|
451
|
-
* items: Array<{ itemId: string, score: number, slot: string, metadata?: object }> }>}
|
|
460
|
+
* items: Array<{ itemId: string, score: number, slot: string, why?: string, metadata?: object }> }>}
|
|
452
461
|
*/
|
|
453
462
|
async recommend(params = {}) {
|
|
454
463
|
const deadline = Math.min(timeoutMs, RECOMMEND_TIMEOUT_MS);
|
|
@@ -476,7 +485,7 @@ export function createClient(options) {
|
|
|
476
485
|
* preflight; a secret key stays in the `Authorization` header.
|
|
477
486
|
*
|
|
478
487
|
* @param {{ tagsAny?: string[], tagsAll?: string[], count?: number }} [params]
|
|
479
|
-
* @returns {Promise<{ items: Array<{ itemId: string, score: number, metadata?: object }> }>}
|
|
488
|
+
* @returns {Promise<{ items: Array<{ itemId: string, score: number, why?: string, metadata?: object }> }>}
|
|
480
489
|
*/
|
|
481
490
|
async popular(params = {}) {
|
|
482
491
|
const items = await fetchPopular(params);
|
|
@@ -492,12 +501,14 @@ export function createClient(options) {
|
|
|
492
501
|
* `GET /v1/users/{id}/history`, fetched by your backend with the secret
|
|
493
502
|
* key) and merges it into the local history. Only pass a snapshot that
|
|
494
503
|
* does not already contain this device's events, or they count twice.
|
|
504
|
+
* Does nothing under Do Not Track: requests name nobody.
|
|
495
505
|
*
|
|
496
506
|
* @param {string} id
|
|
497
507
|
* @param {{ history?: ServerSnapshot }} [options]
|
|
498
508
|
*/
|
|
499
509
|
identify(id, { history: snapshot } = {}) {
|
|
500
510
|
if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
|
|
511
|
+
if (quiet) return;
|
|
501
512
|
mutate(() => {
|
|
502
513
|
if (userId && userId !== id) {
|
|
503
514
|
history = emptyHistory();
|
|
@@ -531,12 +542,13 @@ export function createClient(options) {
|
|
|
531
542
|
* consent nothing is read from or written to storage and no anonymous id
|
|
532
543
|
* exists; the history lives in memory for the current page only.
|
|
533
544
|
* Withdrawing deletes the stored data. Granting merges any previously
|
|
534
|
-
* stored history with the in-memory one.
|
|
545
|
+
* stored history with the in-memory one. Does nothing under Do Not Track:
|
|
546
|
+
* nothing is stored whatever the banner says.
|
|
535
547
|
*
|
|
536
548
|
* @param {boolean} value
|
|
537
549
|
*/
|
|
538
550
|
setConsent(value) {
|
|
539
|
-
if (value === consent) return;
|
|
551
|
+
if (quiet || value === consent) return;
|
|
540
552
|
consent = Boolean(value);
|
|
541
553
|
if (!consent) {
|
|
542
554
|
store.remove();
|
|
@@ -585,12 +597,15 @@ export function createClient(options) {
|
|
|
585
597
|
}
|
|
586
598
|
|
|
587
599
|
/**
|
|
588
|
-
* An item of a list as the SDK returns it. `
|
|
589
|
-
* object, set with the item)
|
|
590
|
-
*
|
|
600
|
+
* An item of a list as the SDK returns it. `why` (the reason code) and
|
|
601
|
+
* `metadata` (the shop's own object, set with the item) are there only when
|
|
602
|
+
* the API sent them, so an item keeps the shape it had before they existed.
|
|
591
603
|
*/
|
|
592
|
-
function toItem({ item_id: itemId, score, metadata }, slot) {
|
|
604
|
+
function toItem({ item_id: itemId, score, why, metadata }, slot) {
|
|
593
605
|
const item = slot === undefined ? { itemId, score } : { itemId, score, slot };
|
|
606
|
+
if (typeof why === 'string' && why !== '') {
|
|
607
|
+
item.why = why;
|
|
608
|
+
}
|
|
594
609
|
if (metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) && Object.keys(metadata).length > 0) {
|
|
595
610
|
item.metadata = metadata;
|
|
596
611
|
}
|