@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 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.3.0/index.js';
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). A default style makes them a product row
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. Consent comes from the
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` | `false` | start without consent when DNT or Global Privacy Control is on |
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
- `respectDoNotTrack: true` starts without consent when the browser sends
529
- DNT or Global Privacy Control.
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
- /** Start without consent when Do Not Track / Global Privacy Control is on. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Browser and Node client for the recapi recommendation API: local decayed history, event batching, attribution.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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] Start without consent when the
84
- * browser sends Do Not Track or Global Privacy Control.
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
- let consent = options.consent ?? true;
131
- if (options.respectDoNotTrack && doNotTrackEnabled()) consent = false;
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. `metadata` (the shop's own
589
- * object, set with the item) is there only when the item has some, so an
590
- * item without it keeps the shape it had before metadata existed.
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
  }