diff --git a/Makefile b/Makefile index 8015751d..1f72432e 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,7 @@ demo-html: envsubst $(DEMO_VARS) < demos/vanilla/targeting/gam360-cached.html.tpl > demos/vanilla/targeting/gam360-cached.html envsubst $(DEMO_VARS) < demos/vanilla/targeting/gam360-adcp.html.tpl > demos/vanilla/targeting/gam360-adcp.html envsubst $(DEMO_VARS) < demos/vanilla/targeting/prebid.html.tpl > demos/vanilla/targeting/prebid.html + envsubst $(DEMO_VARS) < demos/vanilla/targeting/ctx_segments.html.tpl > demos/vanilla/targeting/ctx_segments.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/identify.html.tpl > demos/vanilla/nocookies/identify.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/witness.html.tpl > demos/vanilla/nocookies/witness.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/profile.html.tpl > demos/vanilla/nocookies/profile.html @@ -59,6 +60,7 @@ demo-html: envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/gam360-cached.html.tpl > demos/vanilla/nocookies/targeting/gam360-cached.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/gam360-adcp.html.tpl > demos/vanilla/nocookies/targeting/gam360-adcp.html envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/prebid.html.tpl > demos/vanilla/nocookies/targeting/prebid.html + envsubst $(DEMO_VARS) < demos/vanilla/nocookies/targeting/ctx_segments.html.tpl > demos/vanilla/nocookies/targeting/ctx_segments.html envsubst $(DEMO_VARS) < demos/vanilla/uid2_token/login.html.tpl > demos/vanilla/uid2_token/login.html envsubst $(DEMO_VARS) < demos/vanilla/uid2_token/index.html.tpl > demos/vanilla/uid2_token/index.html envsubst $(DEMO_VARS) < demos/vanilla/pair/index.html.tpl > demos/vanilla/pair/index.html diff --git a/README.md b/README.md index b2771e48..1d63858a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Caching Targeting Data](#caching-targeting-data) - [Witness API](#witness-api) - [Contextual Pageview Tracking](#contextual-pageview-tracking) + - [Contextual Segments API](#contextual-segments-api) + - [Contextual targeting key-values](#contextual-targeting-key-values) - [Using a script tag](#using-a-script-tag) - [Option 1: Automatic Initialization](#option-1-automatic-initialization) - [Option 2: Manual Initialization with Commands Queue](#option-2-manual-initialization-with-commands-queue) @@ -147,8 +149,9 @@ When creating an instance of `OptableSDK`, you can pass an `InitConfig` object t - **`pageContext` (`PageContextConfig | boolean`, default: `undefined`)** When set, enables page context extraction for contextual intelligence. Set to `true` to use defaults, or pass a `PageContextConfig` object to customize what is extracted (HTML content, content selector, max lengths). Extracted context is automatically attached to the first `witness()` call that uses `{ includeContext: true }`. -- **`initContextual` (boolean, default: `false`)** - If `true`, the SDK will automatically fire a `pageview` witness event with full page context during initialization. This is the recommended way to enable contextual pageview tracking without writing custom code. Implies `pageContext: true` when no `pageContext` is explicitly configured. +- **`initContextual` (`boolean | (response: ContextualSegmentsResponse) => void`, default: `false`)** + If `true`, the SDK will automatically fire a `pageview` witness event with full page context during initialization, and also call `ctxSegments()` to fetch contextual segments for the current page, caching the result on the instance for later use via `ctxTargetingKeyValues()`. This is the recommended way to enable contextual pageview tracking and contextual targeting without writing custom code. Implies `pageContext: true` when no `pageContext` is explicitly configured. + When set to a callback function, the SDK does everything `true` does **and** invokes the callback with the `ContextualSegmentsResponse` once it resolves — useful for chaining an ad-server load (e.g. GAM) on the contextual response without making a second `ctxSegments()` call. The callback is not invoked if the request fails. - **`consent` (`InitConsent`)** Defines the consent settings for data collection and processing. @@ -404,6 +407,103 @@ sdk.witness("pageview", { url }, { includeContext: true }); To reset the context (e.g. on SPA navigation), call `sdk.resetContext()` before the next `witness()` call. +### Contextual Segments API + +In addition to pageview tracking, the SDK can classify a page URL against one or more contextual taxonomies (such as the [IAB Content Taxonomy](https://iabtechlab.com/standards/content-taxonomy/)) and use the result for ad targeting. Call `ctxSegments()` to fetch the contextual classifications for a URL: + +```javascript +// Classify the current page (defaults to window.location.href): +const response = await sdk.ctxSegments(); + +// Or classify an explicit URL: +const response = await sdk.ctxSegments("https://example.com/article"); +``` + +The response has the shape: + +```typescript +type ContextualSegmentsResponse = { + classifications: { + categories: { id: string; name: string; score: number; taxonomy: string }[]; + }; +}; +``` + +Each call to `ctxSegments()` caches its response on the SDK instance (calling it again refreshes the cache). When `initContextual: true`, the SDK calls `ctxSegments()` for you during initialization, so the cache is populated automatically. + +> **Note:** The requested URL must already have been classified by the DCN. If the DCN has no classification for the URL, the response will contain an empty `categories` array. + +#### Contextual targeting key-values + +`ctxTargetingKeyValues(taxonomyKeys?)` reads the cached `ctxSegments()` response and builds a `Record` of category ids grouped by taxonomy, ready to pass to an ad server such as Google Ad Manager via `googletag.pubads().setTargeting()`. + +Without arguments, each taxonomy value is used as the key: + +```javascript +sdk.ctxTargetingKeyValues(); +// => { "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"] } +``` + +Pass a `taxonomyKeys` map to rename keys. Only taxonomies present in the map are emitted (filter + rename), which is useful when you only want to set keys you have configured in your ad server: + +```javascript +sdk.ctxTargetingKeyValues({ iab_ct_3_1: "foo" }); +// => { "foo": ["53", "91", "58", "115", "90", "52"] } +``` + +A typical Google Ad Manager activation uses a `loadGAM()` helper: + +```javascript +// Helper to load GAM ads with optional targeting data: +var loadGAM = function (tdata = {}) { + window.googletag = window.googletag || { cmd: [] }; + googletag.cmd.push(function () { + for (const [key, values] of Object.entries(tdata)) { + googletag.pubads().setTargeting(key, values); + } + googletag.pubads().refresh(); + }); +}; +``` + +Because `ctxTargetingKeyValues()` reads the cached response, the instance should be initialized with `initContextual: true` so the segments are fetched during initialization and the cache is likely populated by the time `loadGAM()` runs: + +```javascript +loadGAM(optable.instance.ctxTargetingKeyValues()); +``` + +If you want `loadGAM()` to run as soon as the contextual segments arrive — without making a second `ctxSegments()` call — pass a callback to `initContextual`. The SDK fires the contextual request automatically during initialization and invokes the callback with the response, populating the cache before `ctxTargetingKeyValues()` reads from it: + +```javascript +const sdk = new OptableSDK({ + host: "dcn.customer.com", + site: "my-site", + initContextual: function (response) { + loadGAM(sdk.ctxTargetingKeyValues()); + }, +}); +``` + +If you are not using `initContextual` at all, fetch the segments explicitly and call `loadGAM()` once `ctxSegments()` resolves (falling back to an untargeted load on error): + +```javascript +optable.cmd.push(function () { + optable.instance + .ctxSegments() + .then(loadGAM) + .catch((err) => { + loadGAM(); + }); +}); +``` + +You can also rename and allow-list the GAM keys by passing a `taxonomyKeys` map to `ctxTargetingKeyValues()` (only the taxonomies present in the map are emitted): + +```javascript +// Emit only the "iab_ct_3_1" taxonomy, under the GAM key "ctx_iab": +loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" })); +``` + ## Using a script tag For each [SDK release](https://github.com/Optable/optable-web-sdk/releases), a webpack-generated browser bundle targeting the browsers list described by `pnpm dlx browserslist "> 0.25%, not dead"` can be loaded on a website via a `script` tag. diff --git a/demos/index-nocookies.html b/demos/index-nocookies.html index 72f9fcab..703635c7 100644 --- a/demos/index-nocookies.html +++ b/demos/index-nocookies.html @@ -131,6 +131,14 @@
Audience Targeting
>. + + contextual segments + + Shows how to call ctxSegments() to classify a page URL against contextual taxonomies + (e.g. the IAB Content Taxonomy) and + inspect the categories and confidence scores the DCN returns. + + diff --git a/demos/index.html b/demos/index.html index 4e33676d..f26c196a 100644 --- a/demos/index.html +++ b/demos/index.html @@ -138,6 +138,14 @@
Audience Targeting
>. + + contextual segments + + Shows how to call ctxSegments() to classify a page URL against contextual taxonomies + (e.g. the IAB Content Taxonomy) and + inspect the categories and confidence scores the DCN returns. + + diff --git a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl new file mode 100644 index 00000000..2d357a87 --- /dev/null +++ b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl @@ -0,0 +1,312 @@ + + + + + Optable Web SDK Demos + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+

Example: contextual segments API

+

+ Shows how to call ctxSegments() to classify a page URL against one or more contextual + taxonomies (e.g. the + IAB Content Taxonomy) and inspect the + categories the DCN returns for it. +

+
// Classify the URL of the current page (defaults to window.location.href):
+optable.instance.ctxSegments();
+
+// Or classify an explicit URL:
+optable.instance.ctxSegments("https://optable.co/");
+

+ The response is a ContextualSegmentsResponse of the form + { classifications: { categories: [{ id, name, score, taxonomy }] } }. +

+

+ Alternatively, configure the SDK with initContextual set to a callback. The SDK will + automatically call ctxSegments() for the URL of the current page on initialization, and + invoke the callback with the response as soon as it's available — no second call required: +

+
optable.instance = new optable.SDK({
+  host: "${DCN_HOST}",
+  site: "${DCN_SITE}",
+  node: "${DCN_NODE}",
+  cookies: false,
+  initContextual: function (response) {
+    // Use the response, or read it later via optable.instance.ctxTargetingKeyValues().
+    console.log("contextual segments:", response);
+  },
+});
+
+
+ +
+
+

+ Note: the URL requested must have been classified by the DCN. For the demo DCN used by + this page, the URL https://optable.co/ should have been classified, so you can try that. +

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
Result
+
+
Raw response
+
Click the button to call ctxSegments().
+
+
+ +
+
+
GAM targeting key-values
+

+ Derived from the cached ctxSegments() response via + optable.instance.ctxTargetingKeyValues(), this object can be passed straight to Google Ad + Manager via googletag.pubads().setTargeting(key, values): +

+
var loadGAM = function (tdata = {}) {
+  window.googletag = window.googletag || { cmd: [] };
+  googletag.cmd.push(function () {
+    for (const [key, values] of Object.entries(tdata)) {
+      googletag.pubads().setTargeting(key, values);
+    }
+    googletag.pubads().refresh();
+  });
+};
+

+ ctxTargetingKeyValues() reads the response cached on the SDK instance, so the instance should + be initialized with the initContextual: true option. That way the contextual segments are + fetched during initialization, and the cache is likely to be populated by the time loadGAM() + runs: +

+
loadGAM(optable.instance.ctxTargetingKeyValues());
+

+ If you want loadGAM() to run as soon as the contextual segments arrive — without making a + second ctxSegments() call — pass a callback to initContextual. The SDK fires the + contextual request automatically during initialization and invokes the callback with the response, + populating the cache before ctxTargetingKeyValues() reads from it: +

+
optable.instance = new optable.SDK({
+  host: "${DCN_HOST}",
+  site: "${DCN_SITE}",
+  node: "${DCN_NODE}",
+  cookies: false,
+  initContextual: function (response) {
+    loadGAM(optable.instance.ctxTargetingKeyValues());
+  },
+});
+

+ If you are not using initContextual at all, fetch the segments explicitly and pass the result + to loadGAM() once ctxSegments() resolves (falling back to an untargeted load on + error): +

+
optable.cmd.push(function () {
+  optable.instance
+    .ctxSegments()
+    .then(loadGAM)
+    .catch((err) => {
+      loadGAM();
+    });
+});
+

+ By default each taxonomy is emitted under its own value as the GAM key. Pass a map to + ctxTargetingKeyValues() to rename keys and allow-list which taxonomies are emitted — only + taxonomies present in the map are included: +

+
// Emit only the "iab_ct_3_1" taxonomy, under the GAM key "ctx_iab":
+loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" }));
+
+
+
+ +
+
+
+ Home | Contact | + Terms | + LinkedIn | + Twitter +
+
+
+
+ + + + diff --git a/demos/vanilla/targeting/ctx_segments.html.tpl b/demos/vanilla/targeting/ctx_segments.html.tpl new file mode 100644 index 00000000..8f364e52 --- /dev/null +++ b/demos/vanilla/targeting/ctx_segments.html.tpl @@ -0,0 +1,309 @@ + + + + + Optable Web SDK Demos + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+

Example: contextual segments API

+

+ Shows how to call ctxSegments() to classify a page URL against one or more contextual + taxonomies (e.g. the + IAB Content Taxonomy) and inspect the + categories the DCN returns for it. +

+
// Classify the URL of the current page (defaults to window.location.href):
+optable.instance.ctxSegments();
+
+// Or classify an explicit URL:
+optable.instance.ctxSegments("https://optable.co/");
+

+ The response is a ContextualSegmentsResponse of the form + { classifications: { categories: [{ id, name, score, taxonomy }] } }. +

+

+ Alternatively, configure the SDK with initContextual set to a callback. The SDK will + automatically call ctxSegments() for the URL of the current page on initialization, and + invoke the callback with the response as soon as it's available — no second call required: +

+
optable.instance = new optable.SDK({
+  host: "${DCN_HOST}",
+  site: "${DCN_SITE}",
+  node: "${DCN_NODE}",
+  initContextual: function (response) {
+    // Use the response, or read it later via optable.instance.ctxTargetingKeyValues().
+    console.log("contextual segments:", response);
+  },
+});
+
+
+ +
+
+

+ Note: the URL requested must have been classified by the DCN. For the demo DCN used by + this page, the URL https://optable.co/ should have been classified, so you can try that. +

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
Result
+
+
Raw response
+
Click the button to call ctxSegments().
+
+
+ +
+
+
GAM targeting key-values
+

+ Derived from the cached ctxSegments() response via + optable.instance.ctxTargetingKeyValues(), this object can be passed straight to Google Ad + Manager via googletag.pubads().setTargeting(key, values): +

+
var loadGAM = function (tdata = {}) {
+  window.googletag = window.googletag || { cmd: [] };
+  googletag.cmd.push(function () {
+    for (const [key, values] of Object.entries(tdata)) {
+      googletag.pubads().setTargeting(key, values);
+    }
+    googletag.pubads().refresh();
+  });
+};
+

+ ctxTargetingKeyValues() reads the response cached on the SDK instance, so the instance should + be initialized with the initContextual: true option. That way the contextual segments are + fetched during initialization, and the cache is likely to be populated by the time loadGAM() + runs: +

+
loadGAM(optable.instance.ctxTargetingKeyValues());
+

+ If you want loadGAM() to run as soon as the contextual segments arrive — without making a + second ctxSegments() call — pass a callback to initContextual. The SDK fires the + contextual request automatically during initialization and invokes the callback with the response, + populating the cache before ctxTargetingKeyValues() reads from it: +

+
optable.instance = new optable.SDK({
+  host: "${DCN_HOST}",
+  site: "${DCN_SITE}",
+  node: "${DCN_NODE}",
+  initContextual: function (response) {
+    loadGAM(optable.instance.ctxTargetingKeyValues());
+  },
+});
+

+ If you are not using initContextual at all, fetch the segments explicitly and pass the result + to loadGAM() once ctxSegments() resolves (falling back to an untargeted load on + error): +

+
optable.cmd.push(function () {
+  optable.instance
+    .ctxSegments()
+    .then(loadGAM)
+    .catch((err) => {
+      loadGAM();
+    });
+});
+

+ By default each taxonomy is emitted under its own value as the GAM key. Pass a map to + ctxTargetingKeyValues() to rename keys and allow-list which taxonomies are emitted — only + taxonomies present in the map are included: +

+
// Emit only the "iab_ct_3_1" taxonomy, under the GAM key "ctx_iab":
+loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" }));
+
+
+
+ +
+
+
+ Home | Contact | + Terms | + LinkedIn | + Twitter +
+
+
+
+ + + + diff --git a/lib/config.ts b/lib/config.ts index 68d30750..f8352921 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,6 +1,7 @@ import { getConsent, inferRegulation } from "./core/regs/consent"; import type { CMPApiConfig, Consent } from "./core/regs/consent"; import type { PageContextConfig } from "./core/context"; +import type { ContextualSegmentsResponse } from "./edge/contextual_segments"; type Experiment = never; @@ -72,10 +73,17 @@ type InitConfig = { // When enabled, context is sent with the first witness() call after page load. // Set to true for defaults, or provide a PageContextConfig object for customization. pageContext?: PageContextConfig | boolean; - // Automatically send a pageview witness event with page context on SDK initialization. - // When true, a 'pageview' event is fired once after passport init with full page context. + // Automatically send a pageview witness event with page context on SDK initialization + // and fetch + cache contextual segments for later use by ctxTargetingKeyValues(). + // When true, a 'pageview' witness event is fired once after passport init with full + // page context, and ctxSegments() is called once for the current page so the response + // is cached on the instance. + // When set to a callback function, does everything `true` does AND invokes the callback + // with the ContextualSegmentsResponse once ctxSegments() resolves — useful for chaining + // an ad-server load (e.g. GAM) on the contextual response without making a second + // ctxSegments() call. The callback is not invoked if the request fails. // Implies pageContext: true when no pageContext is explicitly configured. - initContextual?: boolean; + initContextual?: boolean | ((response: ContextualSegmentsResponse) => void); }; type ResolvedConfig = { @@ -93,7 +101,7 @@ type ResolvedConfig = { sessionID: string; skipEnrichment?: boolean; initTargeting?: boolean; - initContextual?: boolean; + initContextual?: boolean | ((response: ContextualSegmentsResponse) => void); abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; timeout?: string; diff --git a/lib/edge/contextual_segments.ts b/lib/edge/contextual_segments.ts new file mode 100644 index 00000000..e3c4af23 --- /dev/null +++ b/lib/edge/contextual_segments.ts @@ -0,0 +1,90 @@ +import type { ResolvedConfig } from "../config"; +import { fetch } from "../core/network"; + +type ContextualSegmentsPayload = { + url: string; +}; + +type ContextualCategory = { + id: string; + name: string; + score: number; + taxonomy: string; +}; + +type ContextualClassifications = { + categories: ContextualCategory[]; +}; + +type ContextualSegmentsResponse = { + classifications: ContextualClassifications; +}; + +async function ContextualSegments(config: ResolvedConfig, url: string): Promise { + const payload: ContextualSegmentsPayload = { + url: url, + }; + + const response: ContextualSegmentsResponse = await fetch("/v1beta1/contextual", config, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + return response; +} + +// Targeting key-values derived from a contextual segments response, suitable for +// passing to ad servers such as GAM via googletag.pubads().setTargeting(key, values). +type ContextualTargetingKeyValues = Record; + +// Builds GAM-style targeting key-values from a contextual segments response by +// grouping category ids under a key derived from each category's taxonomy. +// +// Without taxonomyKeys, the raw taxonomy value is used as the key: +// { "iab_ct_3_1": ["53", "91", ...] } +// +// With taxonomyKeys, only taxonomies present in the map are emitted, renamed to +// the mapped key (filter + rename): +// ContextualTargetingKeyValues(resp, { iab_ct_3_1: "foo" }) => { "foo": ["53", ...] } +function ContextualTargetingKeyValues( + response: ContextualSegmentsResponse | null, + taxonomyKeys?: Record +): ContextualTargetingKeyValues { + const result: ContextualTargetingKeyValues = {}; + const categories = response?.classifications?.categories ?? []; + + for (const category of categories) { + const taxonomy = category?.taxonomy; + if (!taxonomy || category.id == null) { + continue; + } + + let key: string; + if (taxonomyKeys) { + // Filter: only emit taxonomies the caller explicitly mapped. + if (!(taxonomy in taxonomyKeys)) { + continue; + } + key = taxonomyKeys[taxonomy]; + } else { + key = taxonomy; + } + + if (!(key in result)) { + result[key] = []; + } + // Preserve first-seen order, dedupe within a key. + if (!result[key].includes(category.id)) { + result[key].push(category.id); + } + } + + return result; +} + +export { ContextualSegments, ContextualTargetingKeyValues }; +export default ContextualSegments; +export type { ContextualCategory, ContextualClassifications, ContextualSegmentsResponse }; diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index 5a4153f2..2f0d3a4a 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -1,7 +1,9 @@ import { SiteResponse } from "edge/site"; +import { http, HttpResponse } from "msw"; import { OptableSDK, normalizeTargetingRequest } from "./sdk"; import { TEST_BASE_URL, TEST_HOST, TEST_SITE } from "./test/mocks"; import { DCN_DEFAULTS } from "./config"; +import { server } from "./test/server"; import { waitFor } from "./test/utils"; const defaultConsent = DCN_DEFAULTS.consent; @@ -93,6 +95,16 @@ describe("Breaking change detection: if typescript complains or a test fails it' await new OptableSDK({ ...defaultConfig }).witness("event", { property: "value" }); }); + test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: ctxSegments", async () => { + await new OptableSDK({ ...defaultConfig }).ctxSegments("https://optable.co"); + }); + + test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: ctxTargetingKeyValues", () => { + const sdk = new OptableSDK({ ...defaultConfig }); + sdk.ctxTargetingKeyValues(); + sdk.ctxTargetingKeyValues({ iab_ct_3_1: "foo" }); + }); + test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: profile", async () => { await new OptableSDK({ ...defaultConfig }).profile({ propString: "", @@ -395,6 +407,296 @@ describe("behavior testing of", () => { ); }); + test("ctxSegments sends the provided url to /v1beta1/contextual", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + const sdk = new OptableSDK({ ...defaultConfig }); + + await sdk.ctxSegments("https://example.com/some/page"); + expect(fetchSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "POST", + _bodyText: '{"url":"https://example.com/some/page"}', + url: expect.stringContaining("v1beta1/contextual"), + }) + ); + }); + + test("ctxSegments defaults to window.location.href when no url is provided", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + const sdk = new OptableSDK({ ...defaultConfig }); + + await sdk.ctxSegments(); + expect(fetchSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "POST", + _bodyText: JSON.stringify({ url: window.location.href }), + url: expect.stringContaining("v1beta1/contextual"), + }) + ); + }); + + test("ctxSegments returns the parsed classifications response", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json( + { + classifications: { + categories: [{ id: "IAB1", name: "Arts & Entertainment", score: 0.95, taxonomy: "iab_3_1" }], + }, + }, + { status: 200 } + ); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + const result = await sdk.ctxSegments("https://example.com/article"); + + expect(result).toEqual({ + classifications: { + categories: [{ id: "IAB1", name: "Arts & Entertainment", score: 0.95, taxonomy: "iab_3_1" }], + }, + }); + }); + + test("ctxSegments returns an empty categories array when the server returns one", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json({ classifications: { categories: [] } }, { status: 200 }); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + const result = await sdk.ctxSegments("https://example.com/article"); + + expect(result).toEqual({ classifications: { categories: [] } }); + expect(Array.isArray(result.classifications.categories)).toBe(true); + expect(result.classifications.categories).toHaveLength(0); + }); + + test("ctxSegments passes through a response missing the classifications key", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json({}, { status: 200 }); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + const result = await sdk.ctxSegments("https://example.com/article"); + + // Current behavior: no normalization, classifications is undefined on the returned object. + expect(result).toEqual({}); + expect(result.classifications).toBeUndefined(); + }); + + test("ctxSegments passes through classifications missing the categories key", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json({ classifications: {} }, { status: 200 }); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + const result = await sdk.ctxSegments("https://example.com/article"); + + // Current behavior: no normalization, categories is undefined on the returned object. + expect(result.classifications).toEqual({}); + expect(result.classifications.categories).toBeUndefined(); + }); + + test("ctxSegments passes through categories spanning multiple taxonomies", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json( + { + classifications: { + categories: [ + { id: "IAB1", name: "Arts & Entertainment", score: 0.95, taxonomy: "iab_3_1" }, + { id: "483", name: "Motorsports", score: 0.7, taxonomy: "iab_2_2" }, + ], + }, + }, + { status: 200 } + ); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + const result = await sdk.ctxSegments("https://example.com/article"); + + expect(result.classifications.categories).toEqual([ + { id: "IAB1", name: "Arts & Entertainment", score: 0.95, taxonomy: "iab_3_1" }, + { id: "483", name: "Motorsports", score: 0.7, taxonomy: "iab_2_2" }, + ]); + }); + + test("ctxSegments passes through categories missing required fields", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json( + { + classifications: { + categories: [ + // missing `score` + { id: "IAB1", name: "Arts & Entertainment", taxonomy: "iab_3_1" }, + // missing `name` + { id: "IAB2", score: 0.5, taxonomy: "iab_3_1" }, + // missing `taxonomy` + { id: "IAB3", name: "Books & Literature", score: 0.3 }, + ], + }, + }, + { status: 200 } + ); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + const result = await sdk.ctxSegments("https://example.com/article"); + + // Current behavior: no validation, malformed categories are returned to the caller as-is. + expect(result.classifications.categories).toEqual([ + { id: "IAB1", name: "Arts & Entertainment", taxonomy: "iab_3_1" }, + { id: "IAB2", score: 0.5, taxonomy: "iab_3_1" }, + { id: "IAB3", name: "Books & Literature", score: 0.3 }, + ]); + }); + + // Shared single-taxonomy payload mirroring the documented API example. + const singleTaxonomyContextual = { + classifications: { + categories: [ + { id: "53", name: "Business and Finance > Business", score: 0.95, taxonomy: "iab_ct_3_1" }, + { + id: "91", + name: "Business and Finance > Industries > Advertising Industry", + score: 0.98, + taxonomy: "iab_ct_3_1", + }, + { + id: "58", + name: "Business and Finance > Business > Marketing and Advertising", + score: 0.95, + taxonomy: "iab_ct_3_1", + }, + { + id: "115", + name: "Business and Finance > Industries > Technology Industry", + score: 0.85, + taxonomy: "iab_ct_3_1", + }, + { id: "90", name: "Business and Finance > Industries", score: 0.98, taxonomy: "iab_ct_3_1" }, + { id: "52", name: "Business and Finance", score: 0.98, taxonomy: "iab_ct_3_1" }, + ], + }, + }; + + test("ctxTargetingKeyValues returns {} before any ctxSegments call has populated the cache", () => { + const sdk = new OptableSDK({ ...defaultConfig }); + expect(sdk.ctxTargetingKeyValues()).toEqual({}); + expect(sdk.ctxTargetingKeyValues({ iab_ct_3_1: "foo" })).toEqual({}); + }); + + test("ctxSegments caches the response and ctxTargetingKeyValues derives GAM key-values (default keys)", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json(singleTaxonomyContextual, { status: 200 }); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + await sdk.ctxSegments("https://optable.co/"); + + // Default: key is the raw taxonomy value, ids grouped under it in response order. + expect(sdk.ctxTargetingKeyValues()).toEqual({ + iab_ct_3_1: ["53", "91", "58", "115", "90", "52"], + }); + }); + + test("ctxTargetingKeyValues renames taxonomy keys when a map is provided", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json(singleTaxonomyContextual, { status: 200 }); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + await sdk.ctxSegments("https://optable.co/"); + + expect(sdk.ctxTargetingKeyValues({ iab_ct_3_1: "foo" })).toEqual({ + foo: ["53", "91", "58", "115", "90", "52"], + }); + }); + + test("ctxTargetingKeyValues filters out taxonomies absent from the provided map", async () => { + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => { + return HttpResponse.json( + { + classifications: { + categories: [ + { id: "53", name: "Business", score: 0.95, taxonomy: "iab_ct_3_1" }, + { id: "42", name: "Finance", score: 0.9, taxonomy: "iab_ct_3_1" }, + { id: "123", name: "Sports", score: 0.8, taxonomy: "iab_ct_2_2" }, + ], + }, + }, + { status: 200 } + ); + }) + ); + + const sdk = new OptableSDK({ ...defaultConfig }); + await sdk.ctxSegments("https://optable.co/"); + + // Default: both taxonomies emitted under their raw values. + expect(sdk.ctxTargetingKeyValues()).toEqual({ + iab_ct_3_1: ["53", "42"], + iab_ct_2_2: ["123"], + }); + + // Map covers only iab_ct_3_1 -> iab_ct_2_2 is dropped (filter + rename). + expect(sdk.ctxTargetingKeyValues({ iab_ct_3_1: "ctx" })).toEqual({ + ctx: ["53", "42"], + }); + }); + + test("config has initContextual true then constructor sends a contextual request", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + new OptableSDK({ ...defaultConfig, initPassport: false, initContextual: true }); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("v1beta1/contextual"), + }) + ); + }); + }); + + test("config has initContextual as a function then constructor sends a contextual request and invokes the callback with the response", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + const callback = jest.fn(); + new OptableSDK({ ...defaultConfig, initPassport: false, initContextual: callback }); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("v1beta1/contextual"), + }) + ); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + classifications: expect.objectContaining({ categories: expect.any(Array) }), + }) + ); + }); + }); + test("config has initTargeting true then constructor sends a targeting request", async () => { const fetchSpy = jest.spyOn(window, "fetch"); const sdk = new OptableSDK({ ...defaultConfig, initPassport: false, initTargeting: true }); diff --git a/lib/sdk.ts b/lib/sdk.ts index 3f4ba409..3107fea2 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -21,6 +21,11 @@ import { } from "./edge/targeting"; import { Witness } from "./edge/witness"; import { Profile } from "./edge/profile"; +import { + ContextualSegments, + ContextualSegmentsResponse, + ContextualTargetingKeyValues, +} from "./edge/contextual_segments"; import { sha256 } from "js-sha256"; import { Tokenize, TokenizeResponse } from "./edge/tokenize"; import { LocalStorage } from "./core/storage"; @@ -33,6 +38,7 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; + private contextualResponse: ContextualSegmentsResponse | null = null; private passportNullWarned: boolean = false; private visitorIdNullWarned: boolean = false; @@ -57,6 +63,10 @@ class OptableSDK { if (this.dcn.initContextual) { const url = `${window.location.hostname}${window.location.pathname}`; this.witness("pageview", { url }, { includeContext: true }).catch(() => {}); + + const onSegments = typeof this.dcn.initContextual === "function" ? this.dcn.initContextual : null; + const promise = this.ctxSegments(); + (onSegments ? promise.then(onSegments) : promise).catch(() => {}); } } @@ -165,6 +175,16 @@ class OptableSDK { return Profile(this.dcn, traits, id, neighbors); } + async ctxSegments(url?: string): Promise { + const response = await ContextualSegments(this.dcn, url ?? window.location.href); + this.contextualResponse = response; + return response; + } + + ctxTargetingKeyValues(taxonomyKeys?: Record): ContextualTargetingKeyValues { + return ContextualTargetingKeyValues(this.contextualResponse, taxonomyKeys); + } + async tokenize(id: string): Promise { await this.init; return Tokenize(this.dcn, id); diff --git a/lib/test/handlers.ts b/lib/test/handlers.ts index aa1a3229..678425c9 100644 --- a/lib/test/handlers.ts +++ b/lib/test/handlers.ts @@ -6,6 +6,7 @@ import { TokenizeResponse } from "edge/tokenize"; import { TargetingResponse } from "edge/targeting"; import { EdgePassport } from "edge/passport"; import { ResolveResponse } from "edge/resolve"; +import { ContextualSegmentsResponse } from "edge/contextual_segments"; const ok200 = { status: 200, @@ -45,6 +46,13 @@ const handlers = [ return HttpResponse.json({ ...passport }, ok200); }), + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async ({}) => { + const data: ContextualSegmentsResponse = { + classifications: { categories: [] }, + }; + return HttpResponse.json({ ...data, ...passport }, ok200); + }), + http.get(`${TEST_BASE_URL}/v1/resolve`, async ({}) => { const data: ResolveResponse = { clusters: [],