@likyly/sdk 0.0.0-stage → 1.1.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 +286 -2
- package/dist/cjs/client.d.ts +70 -0
- package/dist/cjs/client.js +85 -0
- package/dist/cjs/encoding.d.ts +11 -0
- package/dist/cjs/encoding.js +37 -0
- package/dist/cjs/errors.d.ts +52 -0
- package/dist/cjs/errors.js +83 -0
- package/dist/cjs/http.d.ts +49 -0
- package/dist/cjs/http.js +155 -0
- package/dist/cjs/index.d.ts +8 -0
- package/dist/cjs/index.js +20 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/recommend-cache.d.ts +42 -0
- package/dist/cjs/recommend-cache.js +114 -0
- package/dist/cjs/resources/events.d.ts +45 -0
- package/dist/cjs/resources/events.js +134 -0
- package/dist/cjs/resources/items.d.ts +43 -0
- package/dist/cjs/resources/items.js +97 -0
- package/dist/cjs/resources/placements.d.ts +24 -0
- package/dist/cjs/resources/placements.js +97 -0
- package/dist/cjs/resources/recommendations.d.ts +54 -0
- package/dist/cjs/resources/recommendations.js +130 -0
- package/dist/cjs/resources/users.d.ts +19 -0
- package/dist/cjs/resources/users.js +64 -0
- package/dist/cjs/session.d.ts +32 -0
- package/dist/cjs/session.js +76 -0
- package/dist/cjs/types.d.ts +226 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/version.d.ts +3 -0
- package/dist/cjs/version.js +6 -0
- package/dist/esm/client.d.ts +70 -0
- package/dist/esm/client.js +81 -0
- package/dist/esm/encoding.d.ts +11 -0
- package/dist/esm/encoding.js +31 -0
- package/dist/esm/errors.d.ts +52 -0
- package/dist/esm/errors.js +69 -0
- package/dist/esm/http.d.ts +49 -0
- package/dist/esm/http.js +149 -0
- package/dist/esm/index.d.ts +8 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/recommend-cache.d.ts +42 -0
- package/dist/esm/recommend-cache.js +110 -0
- package/dist/esm/resources/events.d.ts +45 -0
- package/dist/esm/resources/events.js +130 -0
- package/dist/esm/resources/items.d.ts +43 -0
- package/dist/esm/resources/items.js +91 -0
- package/dist/esm/resources/placements.d.ts +24 -0
- package/dist/esm/resources/placements.js +93 -0
- package/dist/esm/resources/recommendations.d.ts +54 -0
- package/dist/esm/resources/recommendations.js +124 -0
- package/dist/esm/resources/users.d.ts +19 -0
- package/dist/esm/resources/users.js +60 -0
- package/dist/esm/session.d.ts +32 -0
- package/dist/esm/session.js +72 -0
- package/dist/esm/types.d.ts +226 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/version.d.ts +3 -0
- package/dist/esm/version.js +3 -0
- package/package.json +60 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ItemsResource = void 0;
|
|
4
|
+
exports.toItem = toItem;
|
|
5
|
+
exports.toBatch = toBatch;
|
|
6
|
+
const encoding_js_1 = require("../encoding.js");
|
|
7
|
+
const errors_js_1 = require("../errors.js");
|
|
8
|
+
/** `likyly.items` - your catalog. Needs the **secret** API key. */
|
|
9
|
+
class ItemsResource {
|
|
10
|
+
http;
|
|
11
|
+
constructor(http) {
|
|
12
|
+
this.http = http;
|
|
13
|
+
}
|
|
14
|
+
/** One item by your own id. */
|
|
15
|
+
async get(itemId, options) {
|
|
16
|
+
const res = await this.http.request({ method: "GET", path: `/items/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requireId)(itemId, "itemId"))}`, idempotent: true, options });
|
|
17
|
+
return toItem(res.data);
|
|
18
|
+
}
|
|
19
|
+
/** One page of the catalog. Pagination is `limit` + `offset` (the API's default is 100 per page); `total` is the whole catalog's size. */
|
|
20
|
+
async list(listOptions = {}, options) {
|
|
21
|
+
const res = await this.http.request({
|
|
22
|
+
method: "GET",
|
|
23
|
+
path: "/items",
|
|
24
|
+
query: { limit: listOptions.limit, offset: listOptions.offset },
|
|
25
|
+
idempotent: true,
|
|
26
|
+
options,
|
|
27
|
+
});
|
|
28
|
+
const total = res.headers.get("x-total-count");
|
|
29
|
+
return { items: res.data.map(toItem), total: total === null ? undefined : Number(total), limit: listOptions.limit, offset: listOptions.offset ?? 0 };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates the item, or replaces it if it exists - idempotent, safe to call as often as you like.
|
|
33
|
+
* The body is the whole item: fields you leave out are cleared.
|
|
34
|
+
*/
|
|
35
|
+
async upsert(itemId, item, options) {
|
|
36
|
+
const res = await this.http.request({
|
|
37
|
+
method: "PUT",
|
|
38
|
+
path: `/items/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requireId)(itemId, "itemId"))}`,
|
|
39
|
+
body: itemBody(item),
|
|
40
|
+
idempotent: true,
|
|
41
|
+
options,
|
|
42
|
+
});
|
|
43
|
+
return toItem(res.data);
|
|
44
|
+
}
|
|
45
|
+
/** Removes the item from the catalog. Events already recorded for it are kept. */
|
|
46
|
+
async delete(itemId, options) {
|
|
47
|
+
await this.http.request({ method: "DELETE", path: `/items/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requireId)(itemId, "itemId"))}`, idempotent: true, options });
|
|
48
|
+
}
|
|
49
|
+
/** Batch upsert (1-1000 items) - each entry behaves like `upsert`. A failing entry is reported in `errors`. */
|
|
50
|
+
async upsertMany(items, options) {
|
|
51
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
52
|
+
throw new errors_js_1.ValidationError("items must be a non-empty array");
|
|
53
|
+
const res = await this.http.request({
|
|
54
|
+
method: "POST",
|
|
55
|
+
path: "/items/import",
|
|
56
|
+
body: { items: items.map((i) => ({ item_id: (0, encoding_js_1.requireId)(i.itemId, "itemId"), ...itemBody(i) })) },
|
|
57
|
+
idempotent: true, // an upsert: replaying it changes nothing
|
|
58
|
+
options,
|
|
59
|
+
});
|
|
60
|
+
return toBatch(res.data);
|
|
61
|
+
}
|
|
62
|
+
/** Alias of {@link upsertMany}: the API's `POST /items/import` is a JSON batch upsert. (A CSV import exists only in the LIKYLY dashboard.) */
|
|
63
|
+
import(items, options) {
|
|
64
|
+
return this.upsertMany(items, options);
|
|
65
|
+
}
|
|
66
|
+
/** Batch delete (1-1000 ids). Ids that don't exist are reported in `errors`. */
|
|
67
|
+
async deleteMany(itemIds, options) {
|
|
68
|
+
if (!Array.isArray(itemIds) || itemIds.length === 0)
|
|
69
|
+
throw new errors_js_1.ValidationError("itemIds must be a non-empty array");
|
|
70
|
+
const res = await this.http.request({
|
|
71
|
+
method: "POST",
|
|
72
|
+
path: "/items/delete",
|
|
73
|
+
body: { item_ids: itemIds.map((id) => (0, encoding_js_1.requireId)(id, "itemId")) },
|
|
74
|
+
idempotent: true,
|
|
75
|
+
options,
|
|
76
|
+
});
|
|
77
|
+
return toBatch(res.data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
exports.ItemsResource = ItemsResource;
|
|
81
|
+
function itemBody(item) {
|
|
82
|
+
if (item === null || typeof item !== "object" || typeof item.title !== "string" || item.title === "") {
|
|
83
|
+
throw new errors_js_1.ValidationError("an item needs a non-empty title");
|
|
84
|
+
}
|
|
85
|
+
return (0, encoding_js_1.compact)({ title: item.title, description: item.description, properties: item.properties });
|
|
86
|
+
}
|
|
87
|
+
function toItem(w) {
|
|
88
|
+
return { itemId: w.item_id, title: w.title, description: w.description ?? undefined, properties: w.properties ?? {} };
|
|
89
|
+
}
|
|
90
|
+
function toBatch(w) {
|
|
91
|
+
return {
|
|
92
|
+
received: w.received,
|
|
93
|
+
succeeded: w.succeeded,
|
|
94
|
+
failed: w.failed,
|
|
95
|
+
errors: (w.errors ?? []).map((e) => ({ index: e.index, id: e.id ?? undefined, message: e.message })),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HttpClient } from "../http.js";
|
|
2
|
+
import type { PlacementRecommendOptions, PlacementRecommendResponse, RequestOptions } from "../types.js";
|
|
3
|
+
import type { RecommendCache } from "../recommend-cache.js";
|
|
4
|
+
/**
|
|
5
|
+
* `likyly.placements` - the recommended way to ask for recommendations. A Placement ("what to
|
|
6
|
+
* recommend, where, with what fallback and filters") is configured once - via the LIKYLY MCP
|
|
7
|
+
* admin tools or dashboard, never from this runtime SDK - and called by `slug` from here on.
|
|
8
|
+
* `likyly.recommendations` (the strategy-specific / signal-based API) remains available for
|
|
9
|
+
* advanced, per-call use with no persistent configuration.
|
|
10
|
+
*/
|
|
11
|
+
export declare class PlacementsResource {
|
|
12
|
+
private readonly http;
|
|
13
|
+
/** null when the client was built with `cache: false`. */
|
|
14
|
+
readonly cache: RecommendCache | null;
|
|
15
|
+
constructor(http: HttpClient,
|
|
16
|
+
/** null when the client was built with `cache: false`. */
|
|
17
|
+
cache?: RecommendCache | null);
|
|
18
|
+
/**
|
|
19
|
+
* Recommendations for one placement: send whatever context you have (`itemId`, `userId`,
|
|
20
|
+
* `sessionId`, ...) - the placement's own configuration decides the strategy, fallback and
|
|
21
|
+
* filters, not you. Send `recommendationId` back on the events that follow to attribute them.
|
|
22
|
+
*/
|
|
23
|
+
recommend(opts: PlacementRecommendOptions, options?: RequestOptions): Promise<PlacementRecommendResponse>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PlacementsResource = void 0;
|
|
4
|
+
const encoding_js_1 = require("../encoding.js");
|
|
5
|
+
const recommendations_js_1 = require("./recommendations.js");
|
|
6
|
+
const errors_js_1 = require("../errors.js");
|
|
7
|
+
/**
|
|
8
|
+
* `likyly.placements` - the recommended way to ask for recommendations. A Placement ("what to
|
|
9
|
+
* recommend, where, with what fallback and filters") is configured once - via the LIKYLY MCP
|
|
10
|
+
* admin tools or dashboard, never from this runtime SDK - and called by `slug` from here on.
|
|
11
|
+
* `likyly.recommendations` (the strategy-specific / signal-based API) remains available for
|
|
12
|
+
* advanced, per-call use with no persistent configuration.
|
|
13
|
+
*/
|
|
14
|
+
class PlacementsResource {
|
|
15
|
+
http;
|
|
16
|
+
cache;
|
|
17
|
+
constructor(http,
|
|
18
|
+
/** null when the client was built with `cache: false`. */
|
|
19
|
+
cache = null) {
|
|
20
|
+
this.http = http;
|
|
21
|
+
this.cache = cache;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Recommendations for one placement: send whatever context you have (`itemId`, `userId`,
|
|
25
|
+
* `sessionId`, ...) - the placement's own configuration decides the strategy, fallback and
|
|
26
|
+
* filters, not you. Send `recommendationId` back on the events that follow to attribute them.
|
|
27
|
+
*/
|
|
28
|
+
async recommend(opts, options) {
|
|
29
|
+
const placement = (0, encoding_js_1.requireId)(opts.placement, "placement");
|
|
30
|
+
const body = (0, encoding_js_1.compact)({ context: contextBody(opts.context), limit: opts.limit });
|
|
31
|
+
const key = this.cache ? cacheKey(placement, body) : "";
|
|
32
|
+
const cached = this.cache?.get(key);
|
|
33
|
+
if (cached)
|
|
34
|
+
return cached;
|
|
35
|
+
if (this.cache?.isPaused()) {
|
|
36
|
+
throw new errors_js_1.PausedError(`LIKYLY did not answer a moment ago: calls resume in ${this.cache.pauseRemaining()}s`);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const res = await this.http.request({
|
|
40
|
+
method: "POST",
|
|
41
|
+
path: `/placements/${(0, encoding_js_1.encodeSegment)(placement)}/recommend`,
|
|
42
|
+
body,
|
|
43
|
+
// A read in effect, but each call mints a fresh recommendationId server-side - repeating
|
|
44
|
+
// it on retry is exactly what you'd want for a dropped connection, not a duplicate write.
|
|
45
|
+
idempotent: true,
|
|
46
|
+
options,
|
|
47
|
+
});
|
|
48
|
+
const result = toPlacementRecommendation(res.data);
|
|
49
|
+
this.cache?.recordSuccess();
|
|
50
|
+
this.cache?.set(key, result);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (isOutage(error))
|
|
55
|
+
this.cache?.recordFailure();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.PlacementsResource = PlacementsResource;
|
|
61
|
+
function contextBody(context) {
|
|
62
|
+
if (!context)
|
|
63
|
+
return undefined;
|
|
64
|
+
return (0, encoding_js_1.compact)({
|
|
65
|
+
current_item_id: context.itemId,
|
|
66
|
+
category_id: context.categoryId,
|
|
67
|
+
collection_id: context.collectionId,
|
|
68
|
+
user_id: context.userId,
|
|
69
|
+
anonymous_id: context.anonymousId,
|
|
70
|
+
session_id: context.sessionId,
|
|
71
|
+
cart_item_ids: context.cartItemIds,
|
|
72
|
+
locale: context.locale,
|
|
73
|
+
custom_context: context.customContext,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function toPlacementRecommendation(w) {
|
|
77
|
+
return {
|
|
78
|
+
recommendationId: w.recommendation_id,
|
|
79
|
+
placement: w.placement,
|
|
80
|
+
strategyUsed: w.strategy_used,
|
|
81
|
+
items: w.items.map(recommendations_js_1.toRecommendedItem),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Same placement, limit and context = same answer; who the anonymous visitor is does not count. */
|
|
85
|
+
function cacheKey(placement, body) {
|
|
86
|
+
const context = { ...(body.context ?? {}) };
|
|
87
|
+
delete context.session_id;
|
|
88
|
+
delete context.anonymous_id;
|
|
89
|
+
const sorted = Object.fromEntries(Object.entries(context).sort(([a], [b]) => a.localeCompare(b)));
|
|
90
|
+
return `${placement}|${body.limit ?? ""}|${JSON.stringify(sorted)}`;
|
|
91
|
+
}
|
|
92
|
+
/** LIKYLY down or overloaded, as opposed to a mistake in the call (4xx). */
|
|
93
|
+
function isOutage(error) {
|
|
94
|
+
if (error instanceof errors_js_1.NetworkError || error instanceof errors_js_1.TimeoutError || error instanceof errors_js_1.RateLimitError)
|
|
95
|
+
return true;
|
|
96
|
+
return error instanceof errors_js_1.LikylyError && (error.statusCode ?? 0) >= 500;
|
|
97
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { HttpClient } from "../http.js";
|
|
2
|
+
import type { CollaborativeOptions, Explanation, HybridOptions, PopularOptions, RecommendationRequest, RecommendationResponse, RecommendedItem, RequestOptions, SessionOptions, SimilarOptions } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* `likyly.recommendations`. Use {@link get}: send what you know and LIKYLY picks the best strategy.
|
|
5
|
+
* The other methods are the *Advanced Recommendations* - one strategy at a time.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RecommendationsResource {
|
|
8
|
+
private readonly http;
|
|
9
|
+
constructor(http: HttpClient);
|
|
10
|
+
/**
|
|
11
|
+
* Recommendations for a user, an anonymous session, an item being viewed, viewed items - or
|
|
12
|
+
* nothing at all (then you get what is popular). You never choose the algorithm; `strategy` in the
|
|
13
|
+
* response says what was used. Send `recommendationId` back on the events that follow.
|
|
14
|
+
*/
|
|
15
|
+
get(request?: RecommendationRequest, options?: RequestOptions): Promise<RecommendationResponse>;
|
|
16
|
+
/** The most popular items - the fallback for a visitor with no history at all. */
|
|
17
|
+
popular(opts?: PopularOptions, options?: RequestOptions): Promise<RecommendationResponse>;
|
|
18
|
+
/** Items similar to one item (content similarity). No user needed. */
|
|
19
|
+
similar(opts: SimilarOptions, options?: RequestOptions): Promise<RecommendationResponse>;
|
|
20
|
+
/** Collaborative filtering: what users with similar histories liked. Needs a trained model. */
|
|
21
|
+
collaborative(opts: CollaborativeOptions, options?: RequestOptions): Promise<RecommendationResponse>;
|
|
22
|
+
/** Similar items, personalized for a user. `alpha` (0-1) weighs the collaborative signal against content similarity. */
|
|
23
|
+
hybrid(opts: HybridOptions, options?: RequestOptions): Promise<RecommendationResponse>;
|
|
24
|
+
/**
|
|
25
|
+
* Recency-weighted recommendations from what was viewed: pass `viewedItemIds` (an explicit list,
|
|
26
|
+
* oldest first) **or** `userId` (LIKYLY's own history of that user's views) - exactly one.
|
|
27
|
+
*/
|
|
28
|
+
session(opts: SessionOptions, options?: RequestOptions): Promise<RecommendationResponse>;
|
|
29
|
+
private advanced;
|
|
30
|
+
}
|
|
31
|
+
export interface WireExplanation {
|
|
32
|
+
reason: string;
|
|
33
|
+
content_similarity?: number | null;
|
|
34
|
+
semantic_similarity?: number | null;
|
|
35
|
+
popularity_score?: number | null;
|
|
36
|
+
interaction_count?: number | null;
|
|
37
|
+
interaction_label?: string | null;
|
|
38
|
+
collaborative_score?: number | null;
|
|
39
|
+
source_item_ids?: string[] | null;
|
|
40
|
+
similar_users?: {
|
|
41
|
+
user_id: string;
|
|
42
|
+
shared_item_ids?: string[];
|
|
43
|
+
}[] | null;
|
|
44
|
+
}
|
|
45
|
+
export interface WireRecommendedItem {
|
|
46
|
+
item_id: string;
|
|
47
|
+
score?: number | null;
|
|
48
|
+
title?: string | null;
|
|
49
|
+
description?: string | null;
|
|
50
|
+
properties?: Record<string, unknown>;
|
|
51
|
+
explanation?: WireExplanation | null;
|
|
52
|
+
}
|
|
53
|
+
export declare function toExplanation(w: WireExplanation): Explanation;
|
|
54
|
+
export declare function toRecommendedItem(w: WireRecommendedItem): RecommendedItem;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RecommendationsResource = void 0;
|
|
4
|
+
exports.toExplanation = toExplanation;
|
|
5
|
+
exports.toRecommendedItem = toRecommendedItem;
|
|
6
|
+
const encoding_js_1 = require("../encoding.js");
|
|
7
|
+
const errors_js_1 = require("../errors.js");
|
|
8
|
+
const DEFAULT_LIMIT = 10;
|
|
9
|
+
/**
|
|
10
|
+
* `likyly.recommendations`. Use {@link get}: send what you know and LIKYLY picks the best strategy.
|
|
11
|
+
* The other methods are the *Advanced Recommendations* - one strategy at a time.
|
|
12
|
+
*/
|
|
13
|
+
class RecommendationsResource {
|
|
14
|
+
http;
|
|
15
|
+
constructor(http) {
|
|
16
|
+
this.http = http;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Recommendations for a user, an anonymous session, an item being viewed, viewed items - or
|
|
20
|
+
* nothing at all (then you get what is popular). You never choose the algorithm; `strategy` in the
|
|
21
|
+
* response says what was used. Send `recommendationId` back on the events that follow.
|
|
22
|
+
*/
|
|
23
|
+
async get(request = {}, options) {
|
|
24
|
+
const res = await this.http.request({
|
|
25
|
+
method: "POST",
|
|
26
|
+
path: "/getRec",
|
|
27
|
+
body: (0, encoding_js_1.compact)({
|
|
28
|
+
user_id: request.userId !== undefined ? (0, encoding_js_1.requireId)(request.userId, "userId") : undefined,
|
|
29
|
+
session_id: request.sessionId !== undefined ? (0, encoding_js_1.requireId)(request.sessionId, "sessionId") : undefined,
|
|
30
|
+
item_id: request.itemId !== undefined ? (0, encoding_js_1.requireId)(request.itemId, "itemId") : undefined,
|
|
31
|
+
viewed_item_ids: request.viewedItemIds?.map((id) => (0, encoding_js_1.requireId)(id, "viewedItemIds[]")),
|
|
32
|
+
placement: request.placement,
|
|
33
|
+
count: request.limit,
|
|
34
|
+
debug: request.debug,
|
|
35
|
+
}),
|
|
36
|
+
idempotent: true, // a read: repeating it only mints another recommendationId
|
|
37
|
+
options,
|
|
38
|
+
});
|
|
39
|
+
return toRecommendation(res.data);
|
|
40
|
+
}
|
|
41
|
+
// ---- Advanced Recommendations ------------------------------------------------------------
|
|
42
|
+
// One strategy at a time, for expert use. Ids travel in the URL path here, so they cannot contain "/".
|
|
43
|
+
/** The most popular items - the fallback for a visitor with no history at all. */
|
|
44
|
+
async popular(opts = {}, options) {
|
|
45
|
+
return this.advanced(`/getRec/popular/${limitOf(opts)}`, opts, {}, options);
|
|
46
|
+
}
|
|
47
|
+
/** Items similar to one item (content similarity). No user needed. */
|
|
48
|
+
async similar(opts, options) {
|
|
49
|
+
return this.advanced(`/getRec/content/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requirePathSafeId)(opts.itemId, "itemId"))}/${limitOf(opts)}`, opts, {}, options);
|
|
50
|
+
}
|
|
51
|
+
/** Collaborative filtering: what users with similar histories liked. Needs a trained model. */
|
|
52
|
+
async collaborative(opts, options) {
|
|
53
|
+
return this.advanced(`/getRec/collaborative/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requirePathSafeId)(opts.userId, "userId"))}/${limitOf(opts)}`, opts, {}, options);
|
|
54
|
+
}
|
|
55
|
+
/** Similar items, personalized for a user. `alpha` (0-1) weighs the collaborative signal against content similarity. */
|
|
56
|
+
async hybrid(opts, options) {
|
|
57
|
+
const path = `/getRec/hybrid/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requirePathSafeId)(opts.userId, "userId"))}/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requirePathSafeId)(opts.itemId, "itemId"))}/${limitOf(opts)}`;
|
|
58
|
+
return this.advanced(path, opts, { alpha: opts.alpha }, options);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Recency-weighted recommendations from what was viewed: pass `viewedItemIds` (an explicit list,
|
|
62
|
+
* oldest first) **or** `userId` (LIKYLY's own history of that user's views) - exactly one.
|
|
63
|
+
*/
|
|
64
|
+
async session(opts, options) {
|
|
65
|
+
const hasList = opts.viewedItemIds !== undefined;
|
|
66
|
+
const hasUser = opts.userId !== undefined;
|
|
67
|
+
if (hasList === hasUser)
|
|
68
|
+
throw new errors_js_1.ValidationError("session() needs either viewedItemIds or userId (exactly one)");
|
|
69
|
+
if (hasUser) {
|
|
70
|
+
return this.advanced(`/getRec/sessionForUser/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requirePathSafeId)(opts.userId, "userId"))}/${limitOf(opts)}`, opts, {}, options);
|
|
71
|
+
}
|
|
72
|
+
const ids = (opts.viewedItemIds ?? []).map((id) => (0, encoding_js_1.requireId)(id, "viewedItemIds[]"));
|
|
73
|
+
if (ids.length === 0)
|
|
74
|
+
throw new errors_js_1.ValidationError("viewedItemIds must contain at least one item id");
|
|
75
|
+
if (ids.some((id) => id.includes(","))) {
|
|
76
|
+
throw new errors_js_1.ValidationError('an item id containing "," cannot be sent in this endpoint\'s comma-separated list - use recommendations.get()');
|
|
77
|
+
}
|
|
78
|
+
return this.advanced("/getRec/session", opts, { viewed_item_ids: ids.join(","), count: limitOf(opts) }, options);
|
|
79
|
+
}
|
|
80
|
+
async advanced(path, opts, extra, options) {
|
|
81
|
+
const res = await this.http.request({
|
|
82
|
+
method: "GET",
|
|
83
|
+
path,
|
|
84
|
+
// response_format=object: always the same {recommendation_id, strategy, items} envelope
|
|
85
|
+
query: { response_format: "object", placement: opts.placement, session_id: opts.sessionId, ...extra },
|
|
86
|
+
idempotent: true,
|
|
87
|
+
options,
|
|
88
|
+
});
|
|
89
|
+
return toRecommendation(res.data);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.RecommendationsResource = RecommendationsResource;
|
|
93
|
+
function limitOf(opts) {
|
|
94
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
95
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
96
|
+
throw new errors_js_1.ValidationError("limit must be a positive integer");
|
|
97
|
+
return limit;
|
|
98
|
+
}
|
|
99
|
+
const opt = (v) => (v === null || v === undefined ? undefined : v);
|
|
100
|
+
function toExplanation(w) {
|
|
101
|
+
return {
|
|
102
|
+
reason: w.reason,
|
|
103
|
+
contentSimilarity: opt(w.content_similarity),
|
|
104
|
+
semanticSimilarity: opt(w.semantic_similarity),
|
|
105
|
+
popularityScore: opt(w.popularity_score),
|
|
106
|
+
interactionCount: opt(w.interaction_count),
|
|
107
|
+
interactionLabel: opt(w.interaction_label),
|
|
108
|
+
collaborativeScore: opt(w.collaborative_score),
|
|
109
|
+
sourceItemIds: opt(w.source_item_ids),
|
|
110
|
+
similarUsers: w.similar_users?.map((u) => ({ userId: u.user_id, sharedItemIds: u.shared_item_ids ?? [] })),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function toRecommendedItem(w) {
|
|
114
|
+
return {
|
|
115
|
+
itemId: w.item_id,
|
|
116
|
+
score: opt(w.score),
|
|
117
|
+
title: opt(w.title),
|
|
118
|
+
description: opt(w.description),
|
|
119
|
+
properties: w.properties ?? {},
|
|
120
|
+
explanation: w.explanation ? toExplanation(w.explanation) : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function toRecommendation(w) {
|
|
124
|
+
return {
|
|
125
|
+
recommendationId: w.recommendation_id,
|
|
126
|
+
strategy: w.strategy,
|
|
127
|
+
placement: opt(w.placement),
|
|
128
|
+
items: w.items.map(toRecommendedItem),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HttpClient } from "../http.js";
|
|
2
|
+
import type { BatchResult, ListOptions, RequestOptions, User, UserImport, UserInput, UserList } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* `likyly.users` - optional user profiles. Needs the **secret** API key (profiles are personal data).
|
|
5
|
+
* You don't have to create a user before sending events for them.
|
|
6
|
+
*/
|
|
7
|
+
export declare class UsersResource {
|
|
8
|
+
private readonly http;
|
|
9
|
+
constructor(http: HttpClient);
|
|
10
|
+
get(userId: string, options?: RequestOptions): Promise<User>;
|
|
11
|
+
/** One page of users. Pagination is `limit` + `offset`; without `limit` the API returns every user. */
|
|
12
|
+
list(listOptions?: ListOptions, options?: RequestOptions): Promise<UserList>;
|
|
13
|
+
/** Creates or replaces the profile (idempotent). `properties` is free-form: country, segment, language, ... */
|
|
14
|
+
upsert(userId: string, user?: UserInput, options?: RequestOptions): Promise<User>;
|
|
15
|
+
/** Erases the user: the profile **and every event recorded for them**. */
|
|
16
|
+
delete(userId: string, options?: RequestOptions): Promise<void>;
|
|
17
|
+
/** Batch upsert (1-1000 users). A failing entry is reported in `errors`. */
|
|
18
|
+
import(users: UserImport[], options?: RequestOptions): Promise<BatchResult>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UsersResource = void 0;
|
|
4
|
+
const encoding_js_1 = require("../encoding.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
6
|
+
const items_js_1 = require("./items.js");
|
|
7
|
+
/**
|
|
8
|
+
* `likyly.users` - optional user profiles. Needs the **secret** API key (profiles are personal data).
|
|
9
|
+
* You don't have to create a user before sending events for them.
|
|
10
|
+
*/
|
|
11
|
+
class UsersResource {
|
|
12
|
+
http;
|
|
13
|
+
constructor(http) {
|
|
14
|
+
this.http = http;
|
|
15
|
+
}
|
|
16
|
+
async get(userId, options) {
|
|
17
|
+
const res = await this.http.request({ method: "GET", path: `/users/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requireId)(userId, "userId"))}`, idempotent: true, options });
|
|
18
|
+
return toUser(res.data);
|
|
19
|
+
}
|
|
20
|
+
/** One page of users. Pagination is `limit` + `offset`; without `limit` the API returns every user. */
|
|
21
|
+
async list(listOptions = {}, options) {
|
|
22
|
+
const res = await this.http.request({
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/users",
|
|
25
|
+
query: { limit: listOptions.limit, offset: listOptions.offset },
|
|
26
|
+
idempotent: true,
|
|
27
|
+
options,
|
|
28
|
+
});
|
|
29
|
+
const total = res.headers.get("x-total-count");
|
|
30
|
+
return { users: res.data.map(toUser), total: total === null ? undefined : Number(total), limit: listOptions.limit, offset: listOptions.offset ?? 0 };
|
|
31
|
+
}
|
|
32
|
+
/** Creates or replaces the profile (idempotent). `properties` is free-form: country, segment, language, ... */
|
|
33
|
+
async upsert(userId, user = {}, options) {
|
|
34
|
+
const res = await this.http.request({
|
|
35
|
+
method: "PUT",
|
|
36
|
+
path: `/users/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requireId)(userId, "userId"))}`,
|
|
37
|
+
body: (0, encoding_js_1.compact)({ properties: user.properties }),
|
|
38
|
+
idempotent: true,
|
|
39
|
+
options,
|
|
40
|
+
});
|
|
41
|
+
return toUser(res.data);
|
|
42
|
+
}
|
|
43
|
+
/** Erases the user: the profile **and every event recorded for them**. */
|
|
44
|
+
async delete(userId, options) {
|
|
45
|
+
await this.http.request({ method: "DELETE", path: `/users/${(0, encoding_js_1.encodeSegment)((0, encoding_js_1.requireId)(userId, "userId"))}`, idempotent: true, options });
|
|
46
|
+
}
|
|
47
|
+
/** Batch upsert (1-1000 users). A failing entry is reported in `errors`. */
|
|
48
|
+
async import(users, options) {
|
|
49
|
+
if (!Array.isArray(users) || users.length === 0)
|
|
50
|
+
throw new errors_js_1.ValidationError("users must be a non-empty array");
|
|
51
|
+
const res = await this.http.request({
|
|
52
|
+
method: "POST",
|
|
53
|
+
path: "/users/import",
|
|
54
|
+
body: { users: users.map((u) => ({ user_id: (0, encoding_js_1.requireId)(u.userId, "userId"), ...(0, encoding_js_1.compact)({ properties: u.properties }) })) },
|
|
55
|
+
idempotent: true,
|
|
56
|
+
options,
|
|
57
|
+
});
|
|
58
|
+
return (0, items_js_1.toBatch)(res.data);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
exports.UsersResource = UsersResource;
|
|
62
|
+
function toUser(w) {
|
|
63
|
+
return { userId: w.user_id, properties: w.properties ?? {} };
|
|
64
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Likyly } from "./client.js";
|
|
2
|
+
import type { IdentifyResult } from "./types.js";
|
|
3
|
+
export interface LikylySessionOptions {
|
|
4
|
+
/** Override the localStorage key - e.g. to namespace multiple LIKYLY-powered apps on one domain. Defaults to "likyly_anonymous_id". */
|
|
5
|
+
storageKey?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A first-party, cookieless anonymous visitor id - generated once per browser and persisted in
|
|
9
|
+
* `localStorage` (never a third-party cookie, never sent to anyone but LIKYLY). Use it as
|
|
10
|
+
* `context.anonymousId` / `sessionId` for recommendations and event tracking until the visitor
|
|
11
|
+
* is identified, then call {@link identify} to attach their history to the now-known user.
|
|
12
|
+
*
|
|
13
|
+
* SSR-safe: every method degrades to a no-op (`undefined`) outside a real browser (no
|
|
14
|
+
* `window`/`localStorage`, private-mode storage that throws, ...) rather than throwing - a
|
|
15
|
+
* server-rendered pass simply has no anonymous id yet, which is correct, not an error.
|
|
16
|
+
*/
|
|
17
|
+
export declare class LikylySession {
|
|
18
|
+
private readonly storageKey;
|
|
19
|
+
private cachedId;
|
|
20
|
+
constructor(options?: LikylySessionOptions);
|
|
21
|
+
/** The visitor's anonymous id, creating and persisting one on first call. `undefined` when
|
|
22
|
+
* there is nowhere to persist it (SSR, Node, storage disabled). */
|
|
23
|
+
getAnonymousId(): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Call once you know the visitor's `userId` (e.g. right after login): attaches this
|
|
26
|
+
* session's *past* interactions to them server-side (`likyly.events.identify`), so they
|
|
27
|
+
* count as that user's history from then on. Safe to call more than once, and safe even if
|
|
28
|
+
* there is no anonymous id yet (a no-op). Both `userId` and the anonymous id remain fine to
|
|
29
|
+
* keep sending together afterwards - this doesn't need to be the last time you use either.
|
|
30
|
+
*/
|
|
31
|
+
identify(likyly: Pick<Likyly, "events">, userId: string): Promise<IdentifyResult | undefined>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LikylySession = void 0;
|
|
4
|
+
const DEFAULT_STORAGE_KEY = "likyly_anonymous_id";
|
|
5
|
+
/**
|
|
6
|
+
* A first-party, cookieless anonymous visitor id - generated once per browser and persisted in
|
|
7
|
+
* `localStorage` (never a third-party cookie, never sent to anyone but LIKYLY). Use it as
|
|
8
|
+
* `context.anonymousId` / `sessionId` for recommendations and event tracking until the visitor
|
|
9
|
+
* is identified, then call {@link identify} to attach their history to the now-known user.
|
|
10
|
+
*
|
|
11
|
+
* SSR-safe: every method degrades to a no-op (`undefined`) outside a real browser (no
|
|
12
|
+
* `window`/`localStorage`, private-mode storage that throws, ...) rather than throwing - a
|
|
13
|
+
* server-rendered pass simply has no anonymous id yet, which is correct, not an error.
|
|
14
|
+
*/
|
|
15
|
+
class LikylySession {
|
|
16
|
+
storageKey;
|
|
17
|
+
cachedId;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
20
|
+
}
|
|
21
|
+
/** The visitor's anonymous id, creating and persisting one on first call. `undefined` when
|
|
22
|
+
* there is nowhere to persist it (SSR, Node, storage disabled). */
|
|
23
|
+
getAnonymousId() {
|
|
24
|
+
if (this.cachedId)
|
|
25
|
+
return this.cachedId;
|
|
26
|
+
const storage = safeLocalStorage();
|
|
27
|
+
if (!storage)
|
|
28
|
+
return undefined;
|
|
29
|
+
try {
|
|
30
|
+
let id = storage.getItem(this.storageKey);
|
|
31
|
+
if (!id) {
|
|
32
|
+
id = generateAnonymousId();
|
|
33
|
+
storage.setItem(this.storageKey, id);
|
|
34
|
+
}
|
|
35
|
+
this.cachedId = id;
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined; // full/blocked storage - degrade to "no session", never throw
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Call once you know the visitor's `userId` (e.g. right after login): attaches this
|
|
44
|
+
* session's *past* interactions to them server-side (`likyly.events.identify`), so they
|
|
45
|
+
* count as that user's history from then on. Safe to call more than once, and safe even if
|
|
46
|
+
* there is no anonymous id yet (a no-op). Both `userId` and the anonymous id remain fine to
|
|
47
|
+
* keep sending together afterwards - this doesn't need to be the last time you use either.
|
|
48
|
+
*/
|
|
49
|
+
async identify(likyly, userId) {
|
|
50
|
+
const sessionId = this.getAnonymousId();
|
|
51
|
+
if (!sessionId)
|
|
52
|
+
return undefined;
|
|
53
|
+
return likyly.events.identify({ userId, sessionId });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.LikylySession = LikylySession;
|
|
57
|
+
function safeLocalStorage() {
|
|
58
|
+
try {
|
|
59
|
+
const storage = globalThis.localStorage;
|
|
60
|
+
if (!storage)
|
|
61
|
+
return undefined;
|
|
62
|
+
const probeKey = "__likyly_probe__";
|
|
63
|
+
storage.setItem(probeKey, "1"); // some private-mode browsers only throw on write, not on presence
|
|
64
|
+
storage.removeItem(probeKey);
|
|
65
|
+
return storage;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function generateAnonymousId() {
|
|
72
|
+
const cryptoObj = globalThis.crypto;
|
|
73
|
+
if (typeof cryptoObj?.randomUUID === "function")
|
|
74
|
+
return `anon_${cryptoObj.randomUUID()}`;
|
|
75
|
+
return `anon_${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
76
|
+
}
|