From b66f2e73ccc1402485d1e3ff193d4ffdd84b5229 Mon Sep 17 00:00:00 2001 From: Bosko Milekic Date: Wed, 3 Jun 2026 12:56:33 -0400 Subject: [PATCH] feat: add ctxSegments() and ctxTargetingKeyValues() for contextual targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a contextual-classification path in the SDK: fetch the DCN's taxonomy/category classifications for a page URL, cache them on the SDK instance, and expose a helper that turns the cached response into GAM-ready targeting key-values. SDK (lib/sdk.ts, lib/edge/contextual_segments.ts): - New OptableSDK.ctxSegments(url?) — POSTs to /v1beta1/contextual and returns ContextualSegmentsResponse { classifications: { categories: [ { taxonomy, id, score, ... } ] } }. url defaults to window.location.href. The contextual request fires immediately (does not await SDK init), so the cache populates as early as possible during initContextual startup. Response is cached on the instance; calling again refreshes the cache. Pass-through behavior (no validation / normalization), matching Targeting() at the edge layer. - initContextual is widened from boolean to boolean | ((response: ContextualSegmentsResponse) => void). When truthy (true or a function), the SDK fires a pageview witness and ctxSegments() at init time (fire-and-forget), so the cache is populated automatically before any consumer reads it. When set to a function, the SDK additionally 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. - New OptableSDK.ctxTargetingKeyValues(taxonomyKeys?) reads the cached response and returns Record of category ids grouped by taxonomy, ready for googletag.pubads().setTargeting(). Without a map, keys are the raw taxonomy values; with a map, keys are renamed and only the listed taxonomies are emitted (filter + rename). Ids are deduped, order preserved. Tests (lib/sdk.test.ts, lib/test/handlers.ts): - MSW handler for /v1beta1/contextual. - Breaking-change tests for ctxSegments and ctxTargetingKeyValues. - Behavioral coverage: happy path, window.location.href default, empty / missing / malformed responses, categories spanning multiple taxonomies, cache population, default keying, rename, filter of unmapped taxonomies, that initContextual: true triggers a contextual request, and that initContextual as a function triggers the request and invokes the callback with the response. Docs & demos (README.md, demos/**): - Document the Contextual Segments API in README: ctxSegments() caching semantics, the initContextual precondition for ctxTargetingKeyValues, the boolean | callback union type, the callback-driven loadGAM pattern, the explicit ctxSegments().then(loadGAM) alternative, key rename/allow-list, and the requirement that the URL be classified by the DCN. - Two new vanilla demo pages — demos/vanilla/targeting/ctx_segments.html.tpl and demos/vanilla/nocookies/targeting/ctx_segments.html.tpl — that call ctxSegments(), render the categories grouped by taxonomy with score bars, show the derived GAM key-values, and display the raw JSON response. Both pages document the initContextual callback pattern in the intro section and in the GAM activation section. - Register both templates in the Makefile demo-html target and link them from the Audience Targeting section of demos/index.html and demos/index-nocookies.html. --- Makefile | 2 + README.md | 104 +++++- demos/index-nocookies.html | 8 + demos/index.html | 8 + .../nocookies/targeting/ctx_segments.html.tpl | 312 ++++++++++++++++++ demos/vanilla/targeting/ctx_segments.html.tpl | 309 +++++++++++++++++ lib/config.ts | 16 +- lib/edge/contextual_segments.ts | 90 +++++ lib/sdk.test.ts | 302 +++++++++++++++++ lib/sdk.ts | 20 ++ lib/test/handlers.ts | 8 + 11 files changed, 1173 insertions(+), 6 deletions(-) create mode 100644 demos/vanilla/nocookies/targeting/ctx_segments.html.tpl create mode 100644 demos/vanilla/targeting/ctx_segments.html.tpl create mode 100644 lib/edge/contextual_segments.ts 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: [],