Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
108 changes: 108 additions & 0 deletions lib/core/gam-contextual-targeting.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
24 changes: 24 additions & 0 deletions lib/core/gam-contextual-targeting.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
options?: ContextualTargetingKeyValuesOptions,
url?: string
): Promise<void> {
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);
}
});
}
23 changes: 20 additions & 3 deletions lib/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContextualSegmentsResponse> } | null = null;
private warned = new Set<string>();

constructor(dcn: InitConfig) {
Expand Down Expand Up @@ -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<ContextualSegmentsResponse> {
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(
Expand Down