@recapi/client 0.4.0 → 0.5.1
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 +73 -8
- package/index.d.ts +4 -1
- package/package.json +1 -1
- package/src/client.js +27 -7
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.1/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
|
|
@@ -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
|
|
@@ -233,6 +245,31 @@ does when its consent API fails to load. It declares compatibility with
|
|
|
233
245
|
WooCommerce's order tables (HPOS) and checkout blocks when installed as a
|
|
234
246
|
plugin, and registers with the WP Consent API.
|
|
235
247
|
|
|
248
|
+
With a secret key pasted on WooCommerce → recapi (stored as the
|
|
249
|
+
`recapi_secret_key` option, not autoloaded, and never printed back), the
|
|
250
|
+
plugin describes the shop's products itself. Each product changed in a
|
|
251
|
+
request (`woocommerce_new_product`, `woocommerce_update_product`, a stock
|
|
252
|
+
status change, a variation's change as its parent's, a status change such as
|
|
253
|
+
trashing or a scheduled product going live, a deletion) is read again at
|
|
254
|
+
the end of the request and sent once the page is out, as one
|
|
255
|
+
`PUT /v1/items/{item_id}` or one `POST /v1/items:batch`: one try of at most
|
|
256
|
+
2 s through WordPress's HTTP API, a failure logged to WooCommerce → Status →
|
|
257
|
+
Logs (source `recapi`), so a recapi outage never fails a save. A WP-Cron
|
|
258
|
+
event (`recapi_sync`, daily, first run when the key is saved) sends the
|
|
259
|
+
whole catalog, 200 products read and 1,000 sent at a time, oldest first,
|
|
260
|
+
with three tries per batch against an outage; the page shows when it last
|
|
261
|
+
ran, how many products it sent and why any were refused. Every item is
|
|
262
|
+
complete: `active` only while the product is published, not hidden from the
|
|
263
|
+
catalog, purchasable and in stock (the Store API cards' rule), its category
|
|
264
|
+
names as `tags` (not "Uncategorized"), its creation date as `published_at`,
|
|
265
|
+
and `name`, `url`, `picture` (the `woocommerce_thumbnail` size) and `price`
|
|
266
|
+
(as the shop displays it, in its currency) as `metadata`, shrunk below
|
|
267
|
+
2,048 bytes as the Magento module does (picture first, then the name). The
|
|
268
|
+
row then draws every card from it, with no Store API call. Only items that
|
|
269
|
+
changed are billed. The plugin uses WordPress's HTTP API rather than the PHP
|
|
270
|
+
library so it stays one file (a snippet holds it) and follows the site's
|
|
271
|
+
proxy settings.
|
|
272
|
+
|
|
236
273
|
Have past orders? Download them from WooCommerce → recapi and upload the
|
|
237
274
|
file on Import data. The plugin writes the last 90 days of processing and
|
|
238
275
|
completed orders as the dashboard's `user_id,item_id,level,timestamp` CSV,
|
|
@@ -256,7 +293,10 @@ The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
|
|
|
256
293
|
the SDK on every storefront page for views and add-to-carts, and sends
|
|
257
294
|
orders from the server with the PHP library. With consent, its script
|
|
258
295
|
mirrors the visitor's id and clicked recommendations into a first-party
|
|
259
|
-
cookie so the server's purchases are credited like the SDK's own.
|
|
296
|
+
cookie so the server's purchases are credited like the SDK's own. On product
|
|
297
|
+
pages it fills recapi's row, drawing each card from the item's `metadata`
|
|
298
|
+
or, without it, from the store; the row is printed with the store's own
|
|
299
|
+
products, which stay while recapi has none.
|
|
260
300
|
|
|
261
301
|
### Node
|
|
262
302
|
|
|
@@ -310,7 +350,7 @@ for (const item of items) {
|
|
|
310
350
|
| `timeoutMs` | `5000` | per-request timeout; `recommend()` waits 1.5 s at most |
|
|
311
351
|
| `historyConfig` | spec defaults | `{ halfLifeSeconds, topK, recentSize }`; must match the server |
|
|
312
352
|
| `consent` | `true` | storage consent, see [Consent](#consent-and-eprivacy) |
|
|
313
|
-
| `respectDoNotTrack` | `
|
|
353
|
+
| `respectDoNotTrack` | `true` | when DNT or Global Privacy Control is on: store nothing, send no events, see [Do Not Track](#do-not-track) |
|
|
314
354
|
| `onError` | – | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
|
|
315
355
|
| `now`, `fetch`, `sleep` | `Date.now`, global `fetch`, `setTimeout` | for tests |
|
|
316
356
|
|
|
@@ -501,7 +541,9 @@ so a batch already in flight when the user navigates away still arrives.
|
|
|
501
541
|
from `GET /v1/users/{id}/history`. Merging adds up the decayed scores
|
|
502
542
|
item by item. Use it only when the snapshot does not already contain
|
|
503
543
|
this device's events, typically right after login. Otherwise those
|
|
504
|
-
events count twice.
|
|
544
|
+
events count twice. The snapshot also holds the anonymous browsing of
|
|
545
|
+
the devices the user logged in on before: on such a device, what was
|
|
546
|
+
browsed there since would count twice.
|
|
505
547
|
- Merge rule for the recent lists: take the union of both lists and order
|
|
506
548
|
it by interaction time, oldest first. An item in both lists takes its
|
|
507
549
|
latest time. Then keep the last 20.
|
|
@@ -550,8 +592,21 @@ if (rec !== null) {
|
|
|
550
592
|
Events are still sent, without `anon_id`. Don't call `track()` if you
|
|
551
593
|
need no network at all. Withdrawing consent deletes the stored data.
|
|
552
594
|
Granting it merges the in-memory history into storage.
|
|
553
|
-
|
|
554
|
-
|
|
595
|
+
|
|
596
|
+
### Do Not Track
|
|
597
|
+
|
|
598
|
+
When the browser sends Do Not Track (`navigator.doNotTrack === '1'`) or
|
|
599
|
+
Global Privacy Control (`navigator.globalPrivacyControl === true`), the
|
|
600
|
+
SDK follows nobody: it reads and writes no storage, `track()` and
|
|
601
|
+
`identify()` do nothing, and `setConsent(true)` doesn't lift it (the
|
|
602
|
+
browser's setting is the visitor's standing answer). `recommend()` and
|
|
603
|
+
`popular()` still work: the request carries no visitor id and no history,
|
|
604
|
+
so the list is popular, trending or similar items (with `contextItemId`).
|
|
605
|
+
The Shopify and WooCommerce snippets and Magento's storefront script
|
|
606
|
+
build on the SDK and behave the same (Magento's order observer runs on the
|
|
607
|
+
server and still sends the purchase). A site with its own legal basis for tracking those visitors
|
|
608
|
+
passes `respectDoNotTrack: false` and gets the behaviour of 0.4.0 and
|
|
609
|
+
earlier.
|
|
555
610
|
|
|
556
611
|
## Custom storage
|
|
557
612
|
|
|
@@ -585,6 +640,16 @@ minifier, to keep zero dev dependencies.
|
|
|
585
640
|
|
|
586
641
|
## Changes
|
|
587
642
|
|
|
643
|
+
- 2026-11-01: version 0.5.1. Without consent, and under Do Not Track, the
|
|
644
|
+
SDK no longer touches `localStorage` at all: before, it wrote and removed a
|
|
645
|
+
probe key when the client was created, to check that storage works. Now
|
|
646
|
+
that check waits for the first read or write, which only consent allows.
|
|
647
|
+
- 2026-11-01: version 0.5.0. Do Not Track and Global Privacy Control are
|
|
648
|
+
respected by default: the SDK then stores nothing and sends no events or
|
|
649
|
+
visitor ids, and still asks for recommendations
|
|
650
|
+
([Do Not Track](#do-not-track)). Before, `respectDoNotTrack` was off by
|
|
651
|
+
default and only stopped storage. Snippets pasted with an older version
|
|
652
|
+
get it too: older versions' paths redirect to the current one.
|
|
588
653
|
- 2026-10-31: version 0.4.0. Recommended and popular items carry `why`,
|
|
589
654
|
a reason code you can turn into a label for visitors
|
|
590
655
|
([Showing why an item is listed](#showing-why-an-item-is-listed)).
|
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;
|
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.
|
|
@@ -120,15 +121,29 @@ export function createClient(options) {
|
|
|
120
121
|
}
|
|
121
122
|
};
|
|
122
123
|
const transport = createTransport({ baseUrl, apiKey, fetch: fetchImpl, timeoutMs });
|
|
124
|
+
// The default adapter probes localStorage with a write, so it is picked at
|
|
125
|
+
// the first storage access: never before consent, nor under Do Not Track.
|
|
126
|
+
let adapter = options.storage;
|
|
127
|
+
const storage = () => (adapter ??= defaultStorage(report));
|
|
123
128
|
const store = createStore(
|
|
124
|
-
|
|
129
|
+
{
|
|
130
|
+
getItem: (key) => storage().getItem(key),
|
|
131
|
+
setItem: (key, value) => storage().setItem(key, value),
|
|
132
|
+
removeItem: (key) => storage().removeItem(key)
|
|
133
|
+
},
|
|
125
134
|
storageKey(options.project ?? apiKey.slice(0, 16)),
|
|
126
135
|
decodePersisted,
|
|
127
136
|
report
|
|
128
137
|
);
|
|
129
138
|
|
|
130
|
-
|
|
131
|
-
|
|
139
|
+
// DNT and GPC ask sites not to follow the visitor, and the homepage
|
|
140
|
+
// promises our script listens: such a page sends recapi nothing about them
|
|
141
|
+
// and keeps nothing on the device, so its lists are popular or similar
|
|
142
|
+
// items only. A banner's consent doesn't lift it: the browser's answer is
|
|
143
|
+
// the visitor's standing one. `respectDoNotTrack: false` is for a site with
|
|
144
|
+
// its own legal basis.
|
|
145
|
+
const quiet = (options.respectDoNotTrack ?? true) && doNotTrackEnabled();
|
|
146
|
+
let consent = !quiet && (options.consent ?? true);
|
|
132
147
|
|
|
133
148
|
/** @type {HistoryState} */
|
|
134
149
|
let history = emptyHistory();
|
|
@@ -400,6 +415,7 @@ export function createClient(options) {
|
|
|
400
415
|
* `reject` is -10). A negative level is an explicit rejection ("not
|
|
401
416
|
* interested"): the item is never recommended to this user again until
|
|
402
417
|
* a positive event on it. Don't send skips or bounces as rejections.
|
|
418
|
+
* Does nothing under Do Not Track (see `respectDoNotTrack`).
|
|
403
419
|
* @param {{ recommendationId?: string | null }} [options] Overrides the
|
|
404
420
|
* automatic attribution. `null` (a fallback list's id) is no override.
|
|
405
421
|
*/
|
|
@@ -415,6 +431,7 @@ export function createClient(options) {
|
|
|
415
431
|
"level must be an integer between -10 and 10, or 'view', 'cart', 'purchase' or 'reject'"
|
|
416
432
|
);
|
|
417
433
|
}
|
|
434
|
+
if (quiet) return;
|
|
418
435
|
const t = now();
|
|
419
436
|
let attributed = recommendationId;
|
|
420
437
|
mutate(() => {
|
|
@@ -492,12 +509,14 @@ export function createClient(options) {
|
|
|
492
509
|
* `GET /v1/users/{id}/history`, fetched by your backend with the secret
|
|
493
510
|
* key) and merges it into the local history. Only pass a snapshot that
|
|
494
511
|
* does not already contain this device's events, or they count twice.
|
|
512
|
+
* Does nothing under Do Not Track: requests name nobody.
|
|
495
513
|
*
|
|
496
514
|
* @param {string} id
|
|
497
515
|
* @param {{ history?: ServerSnapshot }} [options]
|
|
498
516
|
*/
|
|
499
517
|
identify(id, { history: snapshot } = {}) {
|
|
500
518
|
if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
|
|
519
|
+
if (quiet) return;
|
|
501
520
|
mutate(() => {
|
|
502
521
|
if (userId && userId !== id) {
|
|
503
522
|
history = emptyHistory();
|
|
@@ -531,12 +550,13 @@ export function createClient(options) {
|
|
|
531
550
|
* consent nothing is read from or written to storage and no anonymous id
|
|
532
551
|
* exists; the history lives in memory for the current page only.
|
|
533
552
|
* Withdrawing deletes the stored data. Granting merges any previously
|
|
534
|
-
* stored history with the in-memory one.
|
|
553
|
+
* stored history with the in-memory one. Does nothing under Do Not Track:
|
|
554
|
+
* nothing is stored whatever the banner says.
|
|
535
555
|
*
|
|
536
556
|
* @param {boolean} value
|
|
537
557
|
*/
|
|
538
558
|
setConsent(value) {
|
|
539
|
-
if (value === consent) return;
|
|
559
|
+
if (quiet || value === consent) return;
|
|
540
560
|
consent = Boolean(value);
|
|
541
561
|
if (!consent) {
|
|
542
562
|
store.remove();
|