From 4a7d8e695245b59cc21af31ada549e71f82f5257 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 2 Sep 2026 11:40:57 -0400 Subject: [PATCH] contextual: add setContextualTargetingInGAM pushing key-values to GAM --- README.md | 10 ++ lib/core/gam-contextual-targeting.test.ts | 108 ++++++++++++++++++++++ lib/core/gam-contextual-targeting.ts | 24 +++++ lib/sdk.ts | 23 ++++- 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 lib/core/gam-contextual-targeting.test.ts create mode 100644 lib/core/gam-contextual-targeting.ts diff --git a/README.md b/README.md index cc4e72d5..55645815 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,16 @@ You can also rename and allow-list the GAM keys by passing a `taxonomyKeys` map loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" })); ``` +When GAM is the only consumer, `setContextualTargetingInGAM(sdk, taxonomyKeys?, options?, url?)` wraps the fetch-convert-push sequence into one call: it fetches the segments (reusing a classification already in flight or fetched for the same URL, such as by `initContextual`), converts them with `ctxTargetingKeyValues()` (forwarding `taxonomyKeys` and `options`), and queues a `googletag.pubads().setTargeting()` call per key — creating the `googletag` command-queue stub if the page has none yet. Pass `url` to classify a route other than the current location, as in an SPA: + +```javascript +import { setContextualTargetingInGAM } from "@optable/web-sdk/lib/dist/core/gam-contextual-targeting"; + +await setContextualTargetingInGAM(sdk, { iab_ct_3_1: "ctx_iab" }); +``` + +Nothing is queued when the page yields no key-values, and a failed segments fetch rejects — decide caller-side whether to fall back to an untargeted load. + ## 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/lib/core/gam-contextual-targeting.test.ts b/lib/core/gam-contextual-targeting.test.ts new file mode 100644 index 00000000..70cb41db --- /dev/null +++ b/lib/core/gam-contextual-targeting.test.ts @@ -0,0 +1,108 @@ +import { http, HttpResponse } from "msw"; +import { setContextualTargetingInGAM } from "./gam-contextual-targeting"; +import OptableSDK from "../sdk"; +import { TEST_BASE_URL, TEST_HOST, TEST_SITE } from "../test/mocks"; +import { server } from "../test/server"; + +type Category = { taxonomy: string; id: string }; +type Keyword = { keyword: string; prominence: number }; + +function respondWithClassifications(categories: Category[], keywords: Keyword[] = []) { + const calls: string[] = []; + server.use( + http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async ({ request }) => { + calls.push(((await request.json()) as { url?: string })?.url ?? ""); + return HttpResponse.json({ classifications: { categories, keywords } }, { status: 200 }); + }) + ); + return calls; +} + +describe("setContextualTargetingInGAM", () => { + let SDK: OptableSDK; + const w = window as unknown as { googletag?: any }; + + beforeEach(() => { + SDK = new OptableSDK({ host: TEST_HOST, site: TEST_SITE }); + w.googletag = { cmd: [], pubads: jest.fn() }; + }); + + it("pushes contextual key-values to GAM", async () => { + respondWithClassifications([ + { taxonomy: "ctx_iab", id: "IAB1" }, + { taxonomy: "ctx_iab", id: "IAB2" }, + ]); + const setTargeting = jest.fn(); + w.googletag.pubads.mockReturnValue({ setTargeting }); + + await setContextualTargetingInGAM(SDK); + w.googletag.cmd.forEach((cmd: () => void) => cmd()); + + expect(setTargeting).toHaveBeenCalledWith("ctx_iab", ["IAB1", "IAB2"]); + }); + + it("forwards taxonomyKeys to the key-value conversion", async () => { + respondWithClassifications([ + { taxonomy: "ctx_iab", id: "IAB1" }, + { taxonomy: "ctx_other", id: "X1" }, + ]); + const setTargeting = jest.fn(); + w.googletag.pubads.mockReturnValue({ setTargeting }); + + await setContextualTargetingInGAM(SDK, { ctx_iab: "my_key" }); + w.googletag.cmd.forEach((cmd: () => void) => cmd()); + + expect(setTargeting).toHaveBeenCalledTimes(1); + expect(setTargeting).toHaveBeenCalledWith("my_key", ["IAB1"]); + }); + + it("queues nothing when there are no key-values", async () => { + respondWithClassifications([]); + + await setContextualTargetingInGAM(SDK); + + expect(w.googletag.cmd).toHaveLength(0); + }); + + it("creates the googletag stub when the page has none", async () => { + respondWithClassifications([{ taxonomy: "ctx_iab", id: "IAB1" }]); + delete w.googletag; + + await setContextualTargetingInGAM(SDK); + + expect(w.googletag!.cmd).toHaveLength(1); + }); + + it("forwards options to the key-value conversion", async () => { + respondWithClassifications([], [{ keyword: "programmatic", prominence: 1 }]); + const setTargeting = jest.fn(); + w.googletag.pubads.mockReturnValue({ setTargeting }); + + await setContextualTargetingInGAM(SDK, undefined, { keywordKey: "ctx_custom" }); + w.googletag.cmd.forEach((cmd: () => void) => cmd()); + + expect(setTargeting).toHaveBeenCalledTimes(1); + expect(setTargeting).toHaveBeenCalledWith("ctx_custom", ["programmatic"]); + }); + + it("forwards a url override to the segments fetch", async () => { + const calls = respondWithClassifications([{ taxonomy: "ctx_iab", id: "IAB1" }]); + + await setContextualTargetingInGAM(SDK, undefined, undefined, "https://example.com/route"); + + expect(calls).toEqual(["https://example.com/route"]); + }); + + it("reuses a classification already fetched for the same URL", async () => { + const calls = respondWithClassifications([{ taxonomy: "ctx_iab", id: "IAB1" }]); + const setTargeting = jest.fn(); + w.googletag.pubads.mockReturnValue({ setTargeting }); + + await SDK.ctxSegments(); + await setContextualTargetingInGAM(SDK); + w.googletag.cmd.forEach((cmd: () => void) => cmd()); + + expect(calls).toHaveLength(1); + expect(setTargeting).toHaveBeenCalledWith("ctx_iab", ["IAB1"]); + }); +}); diff --git a/lib/core/gam-contextual-targeting.ts b/lib/core/gam-contextual-targeting.ts new file mode 100644 index 00000000..da8d9072 --- /dev/null +++ b/lib/core/gam-contextual-targeting.ts @@ -0,0 +1,24 @@ +import type { ContextualTargetingKeyValuesOptions } from "../edge/contextual_segments"; +import type OptableSDK from "../sdk"; + +// Fetches the page's contextual segments and pushes the resulting key-values +// to GAM via googletag.pubads().setTargeting. taxonomyKeys and options are +// forwarded to ctxTargetingKeyValues(); url to ctxSegments(), for SPAs +// classifying a route other than the current location. +export async function setContextualTargetingInGAM( + sdk: OptableSDK, + taxonomyKeys?: Record, + options?: ContextualTargetingKeyValuesOptions, + url?: string +): Promise { + await sdk.ctxSegments(url); + const kvs = sdk.ctxTargetingKeyValues(taxonomyKeys, options); + if (!Object.keys(kvs).length) return; + + window.googletag = window.googletag || { cmd: [] }; + window.googletag.cmd.push(() => { + for (const [key, values] of Object.entries(kvs)) { + window.googletag.pubads().setTargeting(key, values); + } + }); +} diff --git a/lib/sdk.ts b/lib/sdk.ts index a70dc838..65f8c03a 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -43,6 +43,7 @@ class OptableSDK { private contextSent: boolean = false; private contextConfig: PageContextConfig | null = null; private contextualResponse: ContextualSegmentsResponse | null = null; + private contextualPromise: { url: string; promise: Promise } | null = null; private warned = new Set(); constructor(dcn: InitConfig) { @@ -207,10 +208,26 @@ class OptableSDK { return Profile(this.dcn, traits, id, neighbors); } + // Memoized per URL, so the initContextual fetch and later callers (for + // example setContextualTargetingInGAM) share one classification request. A + // rejected fetch is not memoized, so callers can retry. async ctxSegments(url?: string): Promise { - const response = await ContextualSegments(this.dcn, url ?? window.location.href); - this.contextualResponse = response; - return response; + const target = url ?? window.location.href; + let entry = this.contextualPromise; + if (entry?.url !== target) { + const promise = ContextualSegments(this.dcn, target).then((response) => { + this.contextualResponse = response; + return response; + }); + entry = { url: target, promise }; + this.contextualPromise = entry; + promise.catch(() => { + if (this.contextualPromise?.promise === promise) { + this.contextualPromise = null; + } + }); + } + return entry.promise; } ctxTargetingKeyValues(