diff --git a/README.md b/README.md index c92ad2e..60f955a 100644 --- a/README.md +++ b/README.md @@ -426,30 +426,46 @@ The response has the shape: type ContextualSegmentsResponse = { classifications: { categories: { id: string; name: string; score: number; taxonomy: string }[]; + keywords: { keyword: string; prominence: number }[]; }; }; ``` +`classifications` groups results by classification method; the DCN populates only the methods it has enabled. `categories` are taxonomy classifications (each carrying its own `taxonomy`); `keywords` are free-form terms extracted from the page. A category's `score` is a relevance score from 0 to 1, whereas a keyword's `prominence` is a per-page ordinal rank (1 = most prominent), not a comparable score. + 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. +> **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 empty `categories` and `keywords` arrays. #### 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: +`ctxTargetingKeyValues(taxonomyKeys?, options?)` reads the cached `ctxSegments()` response and builds a `Record`, ready to pass to an ad server such as Google Ad Manager via `googletag.pubads().setTargeting()`. It emits category ids grouped by taxonomy, plus, by default, the page's keywords under the key `ctx_kw`: ```javascript sdk.ctxTargetingKeyValues(); -// => { "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"] } +// => { +// "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"], +// "ctx_kw": ["advertising", "programmatic", "ad tech"] +// } ``` -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: +Pass a `taxonomyKeys` map to rename category 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"] } +// => { "foo": ["53", "91", "58", "115", "90", "52"], "ctx_kw": ["advertising", "programmatic", "ad tech"] } +``` + +Keyword values are ordered by `prominence` (most prominent first), capped to the top 10, and sanitized to GAM's value rules (lowercased, [reserved characters](https://support.google.com/admanager/answer/10020177) stripped, truncated to the 40-character value limit). Use the `options` argument to change the keyword key (`keywordKey`), change the cap (`maxKeywords`), or opt out of keyword key-values by passing an empty `keywordKey`: + +```javascript +// Rename the keyword key and emit only the top 5 keywords: +sdk.ctxTargetingKeyValues({ iab_ct_3_1: "foo" }, { keywordKey: "kw", maxKeywords: 5 }); +// => { "foo": ["53", ...], "kw": ["advertising", "programmatic", "ad tech", "marketing", "audience targeting"] } + +// Opt out of keyword key-values entirely: +sdk.ctxTargetingKeyValues(undefined, { keywordKey: "" }); +// => { "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"] } ``` A typical Google Ad Manager activation uses a `loadGAM()` helper: diff --git a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl index 3bfd06f..63fef96 100644 --- a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl +++ b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl @@ -70,20 +70,58 @@

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. + Shows how to call ctxSegments() to classify a page URL and inspect the classifications the DCN + returns for it: taxonomy categories (e.g. against the + IAB Content Taxonomy) and/or free-form + keywords, depending on which classifiers the DCN has enabled.

// 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:

+
{
+  classifications: {
+    categories: [{ id, name, score, taxonomy }],
+    keywords: [{ keyword, prominence }],
+  },
+}

- The response is a ContextualSegmentsResponse of the form - { classifications: { categories: [{ id, name, score, taxonomy }] } }. + The classifications object groups results by classification method, and the DCN includes only + the methods it has enabled. Two methods exist today:

+
categories: taxonomy classifications
+ + + + + + + + + + + + + +
FieldDescription
idCategory id within its taxonomy (e.g. an IAB category id).
nameHuman-readable category name.
scoreRelevance score from 0 to 1.
taxonomyId of the taxonomy the category belongs to (e.g. iab_ct_3_1).
+
keywords: free-form terms extracted from the page
+ + + + + + + + + + + +
FieldDescription
keywordThe extracted keyword text.
prominence + Per-page ordinal rank (1 = most prominent), not a score, so prominences are not comparable across + pages. +

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 @@ -161,6 +199,15 @@ optable.instance.ctxSegments("https://optable.co/"); runs:

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

+ By default the returned map has one key per taxonomy the DCN classified into (keyed by the raw taxonomy + value) plus the page's keywords under ctx_kw. For example, + ctxTargetingKeyValues() might return: +

+
{
+  "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"],
+  "ctx_kw": ["advertising", "programmatic", "ad tech"]
+}

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 @@ -196,6 +243,18 @@ optable.instance.ctxSegments("https://optable.co/");

// Emit only the "iab_ct_3_1" taxonomy, under the GAM key "ctx_iab":
 loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" }));
+

+ Keyword classifications are also emitted, by default under the GAM key ctx_kw. The values are + the page's keywords ordered by prominence (most prominent first), capped to the top 10, and + sanitized to GAM's value rules (lowercased, reserved characters stripped, truncated to 40 characters). Pass + keywordKey to rename the key or maxKeywords to change the cap, or set + keywordKey to an empty string to opt out of keyword key-values entirely: +

+
// Rename the keyword key and emit only the top 5 keywords:
+loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" }, { keywordKey: "kw", maxKeywords: 5 }));
+
+// Opt out of keyword key-values:
+loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { keywordKey: "" }));
diff --git a/demos/vanilla/targeting/ctx_segments.html.tpl b/demos/vanilla/targeting/ctx_segments.html.tpl index 7f19472..745c0fe 100644 --- a/demos/vanilla/targeting/ctx_segments.html.tpl +++ b/demos/vanilla/targeting/ctx_segments.html.tpl @@ -69,20 +69,58 @@

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. + Shows how to call ctxSegments() to classify a page URL and inspect the classifications the DCN + returns for it: taxonomy categories (e.g. against the + IAB Content Taxonomy) and/or free-form + keywords, depending on which classifiers the DCN has enabled.

// 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:

+
{
+  classifications: {
+    categories: [{ id, name, score, taxonomy }],
+    keywords: [{ keyword, prominence }],
+  },
+}

- The response is a ContextualSegmentsResponse of the form - { classifications: { categories: [{ id, name, score, taxonomy }] } }. + The classifications object groups results by classification method, and the DCN includes only + the methods it has enabled. Two methods exist today:

+
categories: taxonomy classifications
+ + + + + + + + + + + + + +
FieldDescription
idCategory id within its taxonomy (e.g. an IAB category id).
nameHuman-readable category name.
scoreRelevance score from 0 to 1.
taxonomyId of the taxonomy the category belongs to (e.g. iab_ct_3_1).
+
keywords: free-form terms extracted from the page
+ + + + + + + + + + + +
FieldDescription
keywordThe extracted keyword text.
prominence + Per-page ordinal rank (1 = most prominent), not a score, so prominences are not comparable across + pages. +

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 @@ -159,6 +197,15 @@ optable.instance.ctxSegments("https://optable.co/"); runs:

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

+ By default the returned map has one key per taxonomy the DCN classified into (keyed by the raw taxonomy + value) plus the page's keywords under ctx_kw. For example, + ctxTargetingKeyValues() might return: +

+
{
+  "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"],
+  "ctx_kw": ["advertising", "programmatic", "ad tech"]
+}

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 @@ -193,6 +240,18 @@ optable.instance.ctxSegments("https://optable.co/");

// Emit only the "iab_ct_3_1" taxonomy, under the GAM key "ctx_iab":
 loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" }));
+

+ Keyword classifications are also emitted, by default under the GAM key ctx_kw. The values are + the page's keywords ordered by prominence (most prominent first), capped to the top 10, and + sanitized to GAM's value rules (lowercased, reserved characters stripped, truncated to 40 characters). Pass + keywordKey to rename the key or maxKeywords to change the cap, or set + keywordKey to an empty string to opt out of keyword key-values entirely: +

+
// Rename the keyword key and emit only the top 5 keywords:
+loadGAM(optable.instance.ctxTargetingKeyValues({ iab_ct_3_1: "ctx_iab" }, { keywordKey: "kw", maxKeywords: 5 }));
+
+// Opt out of keyword key-values:
+loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { keywordKey: "" }));
diff --git a/lib/edge/contextual_segments.test.ts b/lib/edge/contextual_segments.test.ts new file mode 100644 index 0000000..d9abe04 --- /dev/null +++ b/lib/edge/contextual_segments.test.ts @@ -0,0 +1,119 @@ +import { ContextualTargetingKeyValues } from "./contextual_segments"; +import type { ContextualSegmentsResponse } from "./contextual_segments"; + +// Helper to build a response with the given categories/keywords. Accepts a +// partial so tests can model responses where the DCN omitted a key entirely. +function response(classifications: Partial): ContextualSegmentsResponse { + return { classifications: classifications as ContextualSegmentsResponse["classifications"] }; +} + +describe("ContextualTargetingKeyValues keyword emission", () => { + const withKeywords = response({ + categories: [{ id: "53", name: "Business", score: 0.9, taxonomy: "iab_ct_3_1" }], + keywords: [ + { keyword: "startup", prominence: 2 }, + { keyword: "earnings", prominence: 1 }, + { keyword: "entrepreneur", prominence: 3 }, + ], + }); + + test("emits keywords by default under ctx_kw, sorted by prominence (1 = most prominent first)", () => { + expect(ContextualTargetingKeyValues(withKeywords)).toEqual({ + iab_ct_3_1: ["53"], + ctx_kw: ["earnings", "startup", "entrepreneur"], + }); + // Renaming taxonomy keys does not affect the default keyword key. + expect(ContextualTargetingKeyValues(withKeywords, { iab_ct_3_1: "ctx_iab" })).toEqual({ + ctx_iab: ["53"], + ctx_kw: ["earnings", "startup", "entrepreneur"], + }); + }); + + test("emits keywords under a caller-provided key when keywordKey is set", () => { + expect(ContextualTargetingKeyValues(withKeywords, undefined, { keywordKey: "kw" })).toEqual({ + iab_ct_3_1: ["53"], + kw: ["earnings", "startup", "entrepreneur"], + }); + }); + + test("opts out of keyword emission when keywordKey is an empty string", () => { + expect(ContextualTargetingKeyValues(withKeywords, { iab_ct_3_1: "ctx_iab" }, { keywordKey: "" })).toEqual({ + ctx_iab: ["53"], + }); + }); + + test("caps to maxKeywords by prominence", () => { + expect(ContextualTargetingKeyValues(withKeywords, undefined, { keywordKey: "ctx_kw", maxKeywords: 2 })).toEqual({ + iab_ct_3_1: ["53"], + ctx_kw: ["earnings", "startup"], + }); + }); + + test("defaults to the top 10 keywords when maxKeywords is not given", () => { + const many = response({ + categories: [], + keywords: Array.from({ length: 15 }, (_, i) => ({ keyword: `kw${i}`, prominence: i + 1 })), + }); + const result = ContextualTargetingKeyValues(many, undefined, { keywordKey: "ctx_kw" }); + expect(result.ctx_kw).toHaveLength(10); + expect(result.ctx_kw).toEqual(["kw0", "kw1", "kw2", "kw3", "kw4", "kw5", "kw6", "kw7", "kw8", "kw9"]); + }); + + test("sanitizes values to GAM rules: lowercases, strips disallowed chars, truncates to 40, drops empties", () => { + const dirty = response({ + categories: [], + keywords: [ + { keyword: "Q3 Earnings", prominence: 1 }, // uppercase + space (space allowed) + { keyword: "R&D,budgets", prominence: 2 }, // & and , are disallowed + { keyword: "!!!", prominence: 3 }, // becomes empty -> dropped + { keyword: "a".repeat(50), prominence: 4 }, // too long -> truncated to 40 + ], + }); + const result = ContextualTargetingKeyValues(dirty, undefined, { keywordKey: "ctx_kw" }); + expect(result.ctx_kw).toEqual(["q3 earnings", "rdbudgets", "a".repeat(40)]); + }); + + test("dedupes case-insensitively, preserving prominence order", () => { + const dupes = response({ + categories: [], + keywords: [ + { keyword: "B2B", prominence: 1 }, + { keyword: "b2b", prominence: 2 }, + { keyword: "pricing", prominence: 3 }, + ], + }); + expect(ContextualTargetingKeyValues(dupes, undefined, { keywordKey: "ctx_kw" }).ctx_kw).toEqual(["b2b", "pricing"]); + }); + + test("omits the keyword key entirely when there are no usable keywords", () => { + expect(ContextualTargetingKeyValues(response({ categories: [] }), undefined, { keywordKey: "ctx_kw" })).toEqual({}); + expect( + ContextualTargetingKeyValues(response({ categories: [], keywords: [] }), undefined, { keywordKey: "ctx_kw" }) + ).toEqual({}); + expect( + ContextualTargetingKeyValues( + response({ categories: [], keywords: [{ keyword: "***", prominence: 1 }] }), + undefined, + { + keywordKey: "ctx_kw", + } + ) + ).toEqual({}); + }); + + test("orders keywords with missing/invalid prominence last", () => { + const mixed = response({ + categories: [], + keywords: [ + { keyword: "second", prominence: 5 }, + { keyword: "last", prominence: undefined as unknown as number }, + { keyword: "first", prominence: 1 }, + ], + }); + expect(ContextualTargetingKeyValues(mixed, undefined, { keywordKey: "ctx_kw" }).ctx_kw).toEqual([ + "first", + "second", + "last", + ]); + }); +}); diff --git a/lib/edge/contextual_segments.ts b/lib/edge/contextual_segments.ts index e3c4af2..9e76d55 100644 --- a/lib/edge/contextual_segments.ts +++ b/lib/edge/contextual_segments.ts @@ -12,8 +12,15 @@ type ContextualCategory = { taxonomy: string; }; +type ContextualKeyword = { + keyword: string; + // Per-page ordinal rank (1 = most prominent), not a comparable score. + prominence: number; +}; + type ContextualClassifications = { categories: ContextualCategory[]; + keywords: ContextualKeyword[]; }; type ContextualSegmentsResponse = { @@ -40,6 +47,45 @@ async function ContextualSegments(config: ResolvedConfig, url: string): Promise< // passing to ad servers such as GAM via googletag.pubads().setTargeting(key, values). type ContextualTargetingKeyValues = Record; +// Options for including keyword classifications in the targeting key-values. +type ContextualTargetingKeyValuesOptions = { + // GAM key under which keyword classifications are emitted. Defaults to + // DEFAULT_KEYWORD_KEY when omitted, so keywords are emitted by default. Pass + // an empty string to opt out of keyword emission entirely. + keywordKey?: string; + // Maximum number of keyword values to emit, keeping the most prominent. GAM + // limits the whole ad request URL to 61,440 characters, so keyword output is + // bounded rather than dumping every keyword. Defaults to DEFAULT_MAX_KEYWORDS. + maxKeywords?: number; +}; + +// Default GAM key under which keyword values are emitted. +const DEFAULT_KEYWORD_KEY = "ctx_kw"; + +// Default number of keyword values emitted when maxKeywords is not provided. +const DEFAULT_MAX_KEYWORDS = 10; + +// Characters GAM reserves in custom targeting keys and values, stripped from +// keyword values before emitting. See "Valid key-value entry" in the GAM docs. +const GAM_RESERVED_CHARS = /["'=!+#*~^()<>[\],;&]/g; + +// GAM custom targeting values are capped at 40 characters. +const GAM_MAX_VALUE_LENGTH = 40; + +// Normalizes a keyword into a GAM-safe custom targeting value: lowercases (values +// are case-insensitive), strips GAM-reserved characters, collapses whitespace, +// and truncates to the 40-character value limit. Returns "" if nothing usable +// remains, so the caller can drop it. +function sanitizeGamValue(keyword: string): string { + return keyword + .toLowerCase() + .replace(GAM_RESERVED_CHARS, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, GAM_MAX_VALUE_LENGTH) + .trim(); +} + // Builds GAM-style targeting key-values from a contextual segments response by // grouping category ids under a key derived from each category's taxonomy. // @@ -49,9 +95,21 @@ type ContextualTargetingKeyValues = Record; // 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", ...] } +// +// Keyword classifications are additionally emitted by default under +// DEFAULT_KEYWORD_KEY, sorted by prominence (1 = most prominent), sanitized to +// GAM's value rules, and capped to options.maxKeywords (default +// DEFAULT_MAX_KEYWORDS): +// ContextualTargetingKeyValues(resp) +// => { "iab_ct_3_1": ["53", ...], "ctx_kw": ["nba", "playoffs", ...] } +// +// Pass options.keywordKey to emit keywords under a different key, or an empty +// string to opt out of keyword emission entirely: +// ContextualTargetingKeyValues(resp, undefined, { keywordKey: "" }) // no keywords function ContextualTargetingKeyValues( response: ContextualSegmentsResponse | null, - taxonomyKeys?: Record + taxonomyKeys?: Record, + options?: ContextualTargetingKeyValuesOptions ): ContextualTargetingKeyValues { const result: ContextualTargetingKeyValues = {}; const categories = response?.classifications?.categories ?? []; @@ -82,9 +140,48 @@ function ContextualTargetingKeyValues( } } + // Default the keyword key so keywords are emitted without opting in; an + // explicit empty string opts out. + const keywordKey = options?.keywordKey ?? DEFAULT_KEYWORD_KEY; + if (keywordKey) { + const maxKeywords = options?.maxKeywords ?? DEFAULT_MAX_KEYWORDS; + const keywords = response?.classifications?.keywords ?? []; + // Sort by prominence (1 = most prominent); missing/invalid prominence sorts last. + const prominenceOf = (k: ContextualKeyword): number => + typeof k?.prominence === "number" ? k.prominence : Number.POSITIVE_INFINITY; + + const values: string[] = []; + const seen = new Set(); + for (const keyword of [...keywords].sort((a, b) => prominenceOf(a) - prominenceOf(b))) { + if (values.length >= maxKeywords) { + break; + } + if (typeof keyword?.keyword !== "string") { + continue; + } + const value = sanitizeGamValue(keyword.keyword); + // Drop empties and dedupe (values are case-insensitive to GAM). + if (value.length === 0 || seen.has(value)) { + continue; + } + seen.add(value); + values.push(value); + } + + if (values.length > 0) { + result[keywordKey] = values; + } + } + return result; } export { ContextualSegments, ContextualTargetingKeyValues }; export default ContextualSegments; -export type { ContextualCategory, ContextualClassifications, ContextualSegmentsResponse }; +export type { + ContextualCategory, + ContextualKeyword, + ContextualClassifications, + ContextualSegmentsResponse, + ContextualTargetingKeyValuesOptions, +}; diff --git a/lib/sdk.ts b/lib/sdk.ts index 3107fea..426606c 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -25,6 +25,7 @@ import { ContextualSegments, ContextualSegmentsResponse, ContextualTargetingKeyValues, + ContextualTargetingKeyValuesOptions, } from "./edge/contextual_segments"; import { sha256 } from "js-sha256"; import { Tokenize, TokenizeResponse } from "./edge/tokenize"; @@ -181,8 +182,11 @@ class OptableSDK { return response; } - ctxTargetingKeyValues(taxonomyKeys?: Record): ContextualTargetingKeyValues { - return ContextualTargetingKeyValues(this.contextualResponse, taxonomyKeys); + ctxTargetingKeyValues( + taxonomyKeys?: Record, + options?: ContextualTargetingKeyValuesOptions + ): ContextualTargetingKeyValues { + return ContextualTargetingKeyValues(this.contextualResponse, taxonomyKeys, options); } async tokenize(id: string): Promise {