From 26e69093f30175f931438c32a868a0611289bbf3 Mon Sep 17 00:00:00 2001
From: Pier-Luc Caron St-Pierre
Date: Tue, 3 Nov 2026 08:44:04 -0500
Subject: [PATCH 1/3] contextual: expose page emotions via ctxEmotions() and
the ctx_emotion key-value
Add ContextualEmotion and classifications.emotions, a ctxEmotions()
accessor, and the multi-value ctx_emotion GAM key-value (ids only,
omitted when empty). options.emotionKey renames the key, and "" opts
out. Update the README, gpt.md and the ctx_segments demos.
Co-Authored-By: Claude Opus 5.5
---
README.md | 44 +++++++--
.../nocookies/targeting/ctx_segments.html.tpl | 94 ++++++++++++++++---
demos/vanilla/targeting/ctx_segments.html.tpl | 94 ++++++++++++++++---
lib/addons/gpt.md | 2 +-
lib/edge/contextual_segments.test.ts | 69 ++++++++++++++
lib/edge/contextual_segments.ts | 41 +++++++-
lib/sdk.test.ts | 40 ++++++++
lib/sdk.ts | 8 ++
8 files changed, 360 insertions(+), 32 deletions(-)
diff --git a/README.md b/README.md
index 03c871ea..1bb82a02 100644
--- a/README.md
+++ b/README.md
@@ -437,7 +437,7 @@ The response has the shape:
type ContextualSegmentsResponse = {
classifications: {
categories: { id: string; name: string; score: number; taxonomy: string }[];
- keywords: { keyword: string; prominence: number }[];
+ keywords: { keyword: string; prominence: number /* 1 = most prominent */ }[];
brandSafety: {
assessed: boolean;
categories: { name: string; riskLevel: "low" | "medium" | "high" | "floor" | "not_assessed" | "" }[];
@@ -445,15 +445,20 @@ type ContextualSegmentsResponse = {
sentiment:
| { scored: false }
| { scored: true; id: "positive" | "neutral" | "negative"; name: string; score: number };
+ emotions: {
+ id: "joy" | "trust" | "fear" | "surprise" | "sadness" | "disgust" | "anger" | "anticipation";
+ name: string;
+ score: 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; `brandSafety` is the page's brand-safety assessment, described [below](#brand-safety); `sentiment` is the page's polarity, described [below](#sentiment). 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.
+`classifications` groups results by classification method; the DCN populates only the methods it has enabled.
-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.
-> **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, `brandSafety.assessed` will be `false`, and `sentiment.scored` will be `false`.
+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.
#### Brand safety
@@ -487,7 +492,7 @@ if (sdk.ctxMaxRiskLevel() === "floor") {
#### Sentiment
-`ctxSentiment()` reads the page's sentiment off the cached `ctxSegments()` response:
+`ctxSentiment()` reads the page's sentiment (its polarity) off the cached `ctxSegments()` response:
```javascript
const sentiment = sdk.ctxSentiment();
@@ -500,16 +505,31 @@ if (sentiment.scored) {
The label in `id` is derived by the DCN from the score. Use it as served rather than bucketing `score` yourself, so your targeting follows the DCN's thresholds.
+#### Emotions
+
+`ctxEmotions()` reads the emotions the page evokes off the cached `ctxSegments()` response, as a list of `{ id, name, score }`:
+
+```javascript
+sdk.ctxEmotions();
+// => [{ id: "joy", name: "Joy", score: 0.9 }, { id: "sadness", name: "Sadness", score: 0.7 }]
+```
+
+`id` is one of the eight [Plutchik](https://en.wikipedia.org/wiki/Robert_Plutchik#Plutchik's_wheel_of_emotions) emotions: `joy`, `trust`, `fear`, `surprise`, `sadness`, `disgust`, `anger` or `anticipation`. `score` is from 0.0 to 1.0, and the list is ordered highest score first. It is multi-label: a page can evoke several emotions at once, opposites included (a bittersweet story can carry both `joy` and `sadness`).
+
+The list is empty when the page has no emotion: the DCN never classified it, does not run the emotion classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as served rather than filtering on `score` yourself.
+
#### Contextual targeting key-values
-`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 `ctx_kw` and its brand-safety tier under `ctx_bs_max`:
+`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 `ctx_kw`, its brand-safety tier under `ctx_bs_max`, its sentiment label under `ctx_sentiment` and its emotions under `ctx_emotion`:
```javascript
sdk.ctxTargetingKeyValues();
// => {
// "iab_ct_3_1": ["53", "91", "58", "115", "90", "52"],
// "ctx_kw": ["advertising", "programmatic", "ad tech"],
-// "ctx_bs_max": ["no_flags"]
+// "ctx_bs_max": ["no_flags"],
+// "ctx_sentiment": ["positive"],
+// "ctx_emotion": ["joy", "anticipation"]
// }
```
@@ -568,6 +588,16 @@ sdk.ctxTargetingKeyValues(undefined, { sentimentKey: "" });
// => { "iab_ct_3_1": ["53", ...], "ctx_kw": [...], "ctx_bs_max": ["no_flags"] }
```
+Every one of the page's [emotions](#emotions) is emitted by default under `ctx_emotion`, as a multi-value key (e.g. `ctx_emotion=[joy,sadness]`). Like keywords, the key is omitted when the page has no emotion, and scores are never emitted. Use `emotionKey` to rename the key, or pass an empty `emotionKey` to opt out:
+
+```javascript
+sdk.ctxTargetingKeyValues(undefined, { emotionKey: "feel" });
+// => { "iab_ct_3_1": ["53", ...], "ctx_kw": [...], "ctx_bs_max": ["no_flags"], "feel": ["joy", "sadness"] }
+
+sdk.ctxTargetingKeyValues(undefined, { emotionKey: "" });
+// => { "iab_ct_3_1": ["53", ...], "ctx_kw": [...], "ctx_bs_max": ["no_flags"] }
+```
+
A typical Google Ad Manager activation uses a `loadGAM()` helper:
```javascript
diff --git a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl
index 366ffe87..950e52eb 100644
--- a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl
+++ b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl
@@ -123,6 +123,10 @@
sentiment: polarity of the page
Positive, neutral or negative label, with the score it was derived from.
+
+ emotions: emotions the page evokes
+ Every Plutchik emotion that reached the DCN's cutoff, with its score.
+
@@ -146,8 +150,8 @@
returns for it: taxonomy categories (e.g. against the
IAB Content Taxonomy), free-form keywords,
and/or a brand-safety assessment (against the
- Brand Safety Floor + Suitability Framework), and/or the page's sentiment, depending on which
- classifiers the DCN has enabled.
+ Brand Safety Floor + Suitability Framework), and/or the page's sentiment and emotions, depending
+ on which classifiers the DCN has enabled.
// Classify the URL of the current page (defaults to window.location.href):
optable.instance.ctxSegments();
@@ -161,11 +165,12 @@ optable.instance.ctxSegments("https://optable.co/");
keywords: [{ keyword, prominence }],
brandSafety: { assessed, categories: [{ name, riskLevel }] },
sentiment: { scored, id, name, score },
+ emotions: [{ id, name, score }],
},
}
The classifications object groups results by classification method, and the DCN includes only
- the methods it has enabled. Four methods exist today:
+ the methods it has enabled. Five methods exist today:
categories: taxonomy category ids scored for the page
@@ -311,6 +316,36 @@ optable.instance.ctxSegments("https://optable.co/");
var sentiment = optable.instance.ctxSentiment();
if (sentiment.scored && sentiment.id === "negative") {
// Skip campaigns that should not run next to negative content.
+}
+ emotions: emotions the page evokes
+
+
+ | Field | Description |
+
+
+
+ id |
+
+ Stable label, one of the eight
+ Plutchik
+ emotions: joy, trust, fear, surprise,
+ sadness, disgust, anger or anticipation.
+ |
+
+ name | Human-readable label (e.g. Joy). |
+ score | Strength of the emotion from 0.0 to 1.0. |
+
+
+
+ The list is multi-label and ordered highest score first: a page can evoke several emotions at once,
+ opposites included. It is empty when the DCN never classified the page, does not run the emotion
+ classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as
+ served rather than filtering on the score yourself. ctxEmotions() reads it off the cached
+ response:
+
+ var emotions = optable.instance.ctxEmotions().map(function (e) { return e.id; });
+if (emotions.indexOf("joy") !== -1) {
+ // Favor campaigns that fit uplifting content.
}
Alternatively, configure the SDK with initContextual set to a callback. The SDK will
@@ -334,10 +369,7 @@ if (sentiment.scored && sentiment.id === "negative") {
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. A
- URL the DCN holds no classification for comes back with empty categories and
- keywords arrays, brandSafety.assessed set to false, and
- sentiment.scored set to false.
+ this page, the URL https://optable.co/ should have been classified, so you can try that.
@@ -395,14 +427,16 @@ if (sentiment.scored && sentiment.id === "negative") {
By default the returned map has one key per taxonomy the DCN classified into (keyed by the raw taxonomy
value), the page's keywords under ctx_kw, its brand-safety tier under
- ctx_bs_max, and its sentiment label under ctx_sentiment. For example,
+ ctx_bs_max, its sentiment label under ctx_sentiment, and its emotions under
+ ctx_emotion. For example,
ctxTargetingKeyValues() might return:
{
"iab_ct_3_1": ["53", "91", "58", "115", "90", "52"],
"ctx_kw": ["advertising", "programmatic", "ad tech"],
"ctx_bs_max": ["no_flags"],
- "ctx_sentiment": ["positive"]
+ "ctx_sentiment": ["positive"],
+ "ctx_emotion": ["joy", "anticipation"]
}
If you want loadGAM() to run as soon as the contextual segments arrive — without making a
@@ -512,6 +546,17 @@ loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "mood"
// Opt out of the sentiment key-value:
loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "" }));
+
+ Every one of the page's emotions is emitted by default under
+ ctx_emotion, as a multi-value key (e.g. ctx_emotion=[joy,sadness]). Like keywords,
+ the key is omitted when the page has no emotion, and scores are never emitted. Use
+ emotionKey to rename the key, or pass an empty emotionKey to opt out:
+
+ // Rename the emotion key:
+loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { emotionKey: "feel" }));
+
+// Opt out of the emotion key-value:
+loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { emotionKey: "" }));
—
@@ -555,8 +600,15 @@ loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "" }))
// ctxSegments() has cached the response by now, so the helper reads it
// synchronously off the instance. Missing and unscored both read scored false.
var sentiment = optable.instance.ctxSentiment();
-
- if (categories.length === 0 && keywords.length === 0 && !brandSafety.assessed && !sentiment.scored) {
+ var emotions = optable.instance.ctxEmotions();
+
+ if (
+ categories.length === 0 &&
+ keywords.length === 0 &&
+ !brandSafety.assessed &&
+ !sentiment.scored &&
+ emotions.length === 0
+ ) {
return "No contextual classifications were returned for this URL.
";
}
@@ -679,6 +731,26 @@ loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "" }))
"
";
}
+ if (emotions.length > 0) {
+ html +=
+ "Emotions
" +
+ '| Label | Name | Score |
' +
+ emotions
+ .map(function (e) {
+ return (
+ "" +
+ escapeHtml(e.id) +
+ " | " +
+ escapeHtml(e.name) +
+ " | " +
+ escapeHtml(typeof e.score === "number" ? e.score.toFixed(2) : "n/a") +
+ " |
"
+ );
+ })
+ .join("") +
+ "
";
+ }
+
return html;
}
diff --git a/demos/vanilla/targeting/ctx_segments.html.tpl b/demos/vanilla/targeting/ctx_segments.html.tpl
index 32d56842..959d9753 100644
--- a/demos/vanilla/targeting/ctx_segments.html.tpl
+++ b/demos/vanilla/targeting/ctx_segments.html.tpl
@@ -122,6 +122,10 @@
sentiment: polarity of the page
Positive, neutral or negative label, with the score it was derived from.
+
+ emotions: emotions the page evokes
+ Every Plutchik emotion that reached the DCN's cutoff, with its score.
+
@@ -145,8 +149,8 @@
returns for it: taxonomy categories (e.g. against the
IAB Content Taxonomy), free-form keywords,
and/or a brand-safety assessment (against the
- Brand Safety Floor + Suitability Framework), and/or the page's sentiment, depending on which
- classifiers the DCN has enabled.
+ Brand Safety Floor + Suitability Framework), and/or the page's sentiment and emotions, depending
+ on which classifiers the DCN has enabled.
// Classify the URL of the current page (defaults to window.location.href):
optable.instance.ctxSegments();
@@ -160,11 +164,12 @@ optable.instance.ctxSegments("https://optable.co/");
keywords: [{ keyword, prominence }],
brandSafety: { assessed, categories: [{ name, riskLevel }] },
sentiment: { scored, id, name, score },
+ emotions: [{ id, name, score }],
},
}
The classifications object groups results by classification method, and the DCN includes only
- the methods it has enabled. Four methods exist today:
+ the methods it has enabled. Five methods exist today:
categories: taxonomy category ids scored for the page
@@ -310,6 +315,36 @@ optable.instance.ctxSegments("https://optable.co/");
var sentiment = optable.instance.ctxSentiment();
if (sentiment.scored && sentiment.id === "negative") {
// Skip campaigns that should not run next to negative content.
+}
+ emotions: emotions the page evokes
+
+
+ | Field | Description |
+
+
+
+ id |
+
+ Stable label, one of the eight
+ Plutchik
+ emotions: joy, trust, fear, surprise,
+ sadness, disgust, anger or anticipation.
+ |
+
+ name | Human-readable label (e.g. Joy). |
+ score | Strength of the emotion from 0.0 to 1.0. |
+
+
+
+ The list is multi-label and ordered highest score first: a page can evoke several emotions at once,
+ opposites included. It is empty when the DCN never classified the page, does not run the emotion
+ classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as
+ served rather than filtering on the score yourself. ctxEmotions() reads it off the cached
+ response:
+
+ var emotions = optable.instance.ctxEmotions().map(function (e) { return e.id; });
+if (emotions.indexOf("joy") !== -1) {
+ // Favor campaigns that fit uplifting content.
}
Alternatively, configure the SDK with initContextual set to a callback. The SDK will
@@ -332,10 +367,7 @@ if (sentiment.scored && sentiment.id === "negative") {
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. A
- URL the DCN holds no classification for comes back with empty categories and
- keywords arrays, brandSafety.assessed set to false, and
- sentiment.scored set to false.
+ this page, the URL https://optable.co/ should have been classified, so you can try that.
@@ -393,14 +425,16 @@ if (sentiment.scored && sentiment.id === "negative") {
By default the returned map has one key per taxonomy the DCN classified into (keyed by the raw taxonomy
value), the page's keywords under ctx_kw, its brand-safety tier under
- ctx_bs_max, and its sentiment label under ctx_sentiment. For example,
+ ctx_bs_max, its sentiment label under ctx_sentiment, and its emotions under
+ ctx_emotion. For example,
ctxTargetingKeyValues() might return:
{
"iab_ct_3_1": ["53", "91", "58", "115", "90", "52"],
"ctx_kw": ["advertising", "programmatic", "ad tech"],
"ctx_bs_max": ["no_flags"],
- "ctx_sentiment": ["positive"]
+ "ctx_sentiment": ["positive"],
+ "ctx_emotion": ["joy", "anticipation"]
}
If you want loadGAM() to run as soon as the contextual segments arrive — without making a
@@ -509,6 +543,17 @@ loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "mood"
// Opt out of the sentiment key-value:
loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "" }));
+
+ Every one of the page's emotions is emitted by default under
+ ctx_emotion, as a multi-value key (e.g. ctx_emotion=[joy,sadness]). Like keywords,
+ the key is omitted when the page has no emotion, and scores are never emitted. Use
+ emotionKey to rename the key, or pass an empty emotionKey to opt out:
+
+ // Rename the emotion key:
+loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { emotionKey: "feel" }));
+
+// Opt out of the emotion key-value:
+loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { emotionKey: "" }));
—
@@ -552,8 +597,15 @@ loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "" }))
// ctxSegments() has cached the response by now, so the helper reads it
// synchronously off the instance. Missing and unscored both read scored false.
var sentiment = optable.instance.ctxSentiment();
-
- if (categories.length === 0 && keywords.length === 0 && !brandSafety.assessed && !sentiment.scored) {
+ var emotions = optable.instance.ctxEmotions();
+
+ if (
+ categories.length === 0 &&
+ keywords.length === 0 &&
+ !brandSafety.assessed &&
+ !sentiment.scored &&
+ emotions.length === 0
+ ) {
return "No contextual classifications were returned for this URL.
";
}
@@ -676,6 +728,26 @@ loadGAM(optable.instance.ctxTargetingKeyValues(undefined, { sentimentKey: "" }))
"
";
}
+ if (emotions.length > 0) {
+ html +=
+ "Emotions
" +
+ '| Label | Name | Score |
' +
+ emotions
+ .map(function (e) {
+ return (
+ "" +
+ escapeHtml(e.id) +
+ " | " +
+ escapeHtml(e.name) +
+ " | " +
+ escapeHtml(typeof e.score === "number" ? e.score.toFixed(2) : "n/a") +
+ " |
"
+ );
+ })
+ .join("") +
+ "
";
+ }
+
return html;
}
diff --git a/lib/addons/gpt.md b/lib/addons/gpt.md
index 18be687c..bb04fb1e 100644
--- a/lib/addons/gpt.md
+++ b/lib/addons/gpt.md
@@ -28,7 +28,7 @@ Arguments after the SDK instance are optional. `taxonomyKeys` and `options` are
### Keys
-Category ids are grouped under their taxonomy name, so a response classified under `iab_ct_3_1` produces that key unless `taxonomyKeys` renames it. Page keywords are emitted under `ctx_kw`, capped at ten and sanitized to GAM's value rules; `options.keywordKey` renames that key and an empty string opts out. The page's sentiment label (`positive`, `neutral` or `negative`) is emitted under `ctx_sentiment`, omitted when the page has no sentiment; `options.sentimentKey` renames that key and an empty string opts out. See [contextual targeting key-values](../../README.md#contextual-targeting-key-values) for the full conversion rules.
+Category ids are grouped under their taxonomy name, so a response classified under `iab_ct_3_1` produces that key unless `taxonomyKeys` renames it. Page keywords are emitted under `ctx_kw`, capped at ten and sanitized to GAM's value rules; `options.keywordKey` renames that key and an empty string opts out. The page's sentiment label (`positive`, `neutral` or `negative`) is emitted under `ctx_sentiment`, omitted when the page has no sentiment; `options.sentimentKey` renames that key and an empty string opts out. Every emotion the page evokes (e.g. `joy`, `sadness`) is emitted under `ctx_emotion` as one multi-value key, omitted when the page has none; `options.emotionKey` renames that key and an empty string opts out. See [contextual targeting key-values](../../README.md#contextual-targeting-key-values) for the full conversion rules.
### Behaviour
diff --git a/lib/edge/contextual_segments.test.ts b/lib/edge/contextual_segments.test.ts
index a073bab9..82ed8dd9 100644
--- a/lib/edge/contextual_segments.test.ts
+++ b/lib/edge/contextual_segments.test.ts
@@ -1,5 +1,6 @@
import {
ContextualBrandSafetyOf,
+ ContextualEmotionsOf,
ContextualMaxRiskLevel,
ContextualSentimentOf,
ContextualTargetingKeyValues,
@@ -7,6 +8,7 @@ import {
import type {
ContextualBrandSafety,
ContextualBrandSafetyCategory,
+ ContextualEmotion,
ContextualSegmentsResponse,
ContextualSentiment,
} from "./contextual_segments";
@@ -385,3 +387,70 @@ describe("ContextualTargetingKeyValues sentiment emission", () => {
});
});
});
+
+describe("ContextualEmotionsOf", () => {
+ test("returns every emotion as served, opposites included", () => {
+ const served: ContextualEmotion[] = [
+ { id: "joy", name: "Joy", score: 0.9 },
+ { id: "sadness", name: "Sadness", score: 0.7 },
+ { id: "anticipation", name: "Anticipation", score: 0.4 },
+ ];
+
+ expect(ContextualEmotionsOf(response({ categories: [], emotions: served }))).toEqual(served);
+ });
+
+ // A DCN on an edge build predating emotion serving omits the field, the same
+ // answer it gives for a node not running the classifier.
+ test("folds an empty, missing or null response into an empty list", () => {
+ expect(ContextualEmotionsOf(response({ categories: [], emotions: [] }))).toEqual([]);
+ expect(ContextualEmotionsOf(response({ categories: [] }))).toEqual([]);
+ expect(ContextualEmotionsOf(null)).toEqual([]);
+ });
+});
+
+describe("ContextualTargetingKeyValues emotion emission", () => {
+ function emotions(e: ContextualEmotion[] | undefined): ContextualSegmentsResponse {
+ return response({
+ categories: [{ id: "53", name: "Business", score: 0.9, taxonomy: "iab_ct_3_1" }],
+ keywords: [],
+ emotions: e,
+ });
+ }
+
+ const mixed = emotions([
+ { id: "joy", name: "Joy", score: 0.9 },
+ { id: "sadness", name: "Sadness", score: 0.7 },
+ ]);
+
+ test("emits every id by default under ctx_emotion, never the scores", () => {
+ expect(ContextualTargetingKeyValues(mixed, undefined, { brandSafetyKey: "" })).toEqual({
+ iab_ct_3_1: ["53"],
+ ctx_emotion: ["joy", "sadness"],
+ });
+ });
+
+ // Edge owns the cutoff, so a low score it chose to serve is still emitted.
+ test("emits every served emotion regardless of score", () => {
+ const low = emotions([{ id: "fear", name: "Fear", score: 0.05 }]);
+
+ expect(ContextualTargetingKeyValues(low, undefined, { brandSafetyKey: "" }).ctx_emotion).toEqual(["fear"]);
+ });
+
+ test("omits the key when the page has no emotion", () => {
+ const options = { brandSafetyKey: "" };
+
+ expect(ContextualTargetingKeyValues(emotions([]), undefined, options)).toEqual({ iab_ct_3_1: ["53"] });
+ expect(ContextualTargetingKeyValues(emotions(undefined), undefined, options)).toEqual({ iab_ct_3_1: ["53"] });
+ expect(ContextualTargetingKeyValues(null, undefined, options)).toEqual({});
+ });
+
+ test("emits under a caller-provided key, and opts out on an empty key", () => {
+ expect(ContextualTargetingKeyValues(mixed, undefined, { brandSafetyKey: "", emotionKey: "mood" })).toEqual({
+ iab_ct_3_1: ["53"],
+ mood: ["joy", "sadness"],
+ });
+ expect(ContextualTargetingKeyValues(mixed, undefined, { brandSafetyKey: "", emotionKey: "" })).toEqual({
+ iab_ct_3_1: ["53"],
+ });
+ });
+});
diff --git a/lib/edge/contextual_segments.ts b/lib/edge/contextual_segments.ts
index 344bb57c..76020c09 100644
--- a/lib/edge/contextual_segments.ts
+++ b/lib/edge/contextual_segments.ts
@@ -62,11 +62,24 @@ type ContextualSentiment =
| { scored: false }
| { scored: true; id: ContextualSentimentLabel; name: string; score: number };
+// The eight Plutchik emotions, as their API values.
+type ContextualEmotionLabel = "joy" | "trust" | "fear" | "surprise" | "sadness" | "disgust" | "anger" | "anticipation";
+
+// One emotion the page evokes, with the model's score from 0.0 to 1.0. The DCN
+// serves only the emotions that pass its cutoff, so none is filtered here.
+type ContextualEmotion = {
+ id: ContextualEmotionLabel;
+ name: string;
+ score: number;
+};
+
type ContextualClassifications = {
categories: ContextualCategory[];
keywords: ContextualKeyword[];
brandSafety: ContextualBrandSafety;
sentiment: ContextualSentiment;
+ // Multi-label, highest score first, opposites included.
+ emotions: ContextualEmotion[];
};
type ContextualSegmentsResponse = {
@@ -159,12 +172,19 @@ function ContextualSentimentOf(response: ContextualSegmentsResponse | null): Con
return sentiment;
}
+// Reads the emotions off a contextual segments response. A DCN on a build
+// predating emotion serving omits the field, which folds into an empty list
+// like a node that does not run the classifier.
+function ContextualEmotionsOf(response: ContextualSegmentsResponse | null): ContextualEmotion[] {
+ return response?.classifications?.emotions ?? [];
+}
+
// 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;
-// Options for including keyword, brand-safety and sentiment classifications in
-// the targeting key-values.
+// Options for including keyword, brand-safety, sentiment and emotion
+// 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
@@ -186,6 +206,10 @@ type ContextualTargetingKeyValuesOptions = {
// DEFAULT_SENTIMENT_KEY when omitted. Pass an empty string to opt out. Like
// keywords, the key is dropped when the page has no sentiment.
sentimentKey?: string;
+ // GAM key under which the page's emotion ids are emitted, as one multi-value
+ // key. Defaults to DEFAULT_EMOTION_KEY when omitted. Pass an empty string to
+ // opt out. Like keywords, the key is dropped when the page has no emotion.
+ emotionKey?: string;
};
// Default GAM key under which keyword values are emitted.
@@ -200,6 +224,9 @@ const DEFAULT_BRAND_SAFETY_KEY = "ctx_bs_max";
// Default GAM key under which the sentiment label is emitted.
const DEFAULT_SENTIMENT_KEY = "ctx_sentiment";
+// Default GAM key under which the emotion ids are emitted.
+const DEFAULT_EMOTION_KEY = "ctx_emotion";
+
// Brand-safety value emitted when no assessment backs the page: it was never
// classified, the read failed, or the DCN does not run the classifier.
//
@@ -339,6 +366,13 @@ function ContextualTargetingKeyValues(
result[sentimentKey] = [sentiment.id];
}
+ // The ids only, for the same reason as sentiment.
+ const emotionKey = options?.emotionKey ?? DEFAULT_EMOTION_KEY;
+ const emotions = ContextualEmotionsOf(response);
+ if (emotionKey && emotions.length > 0) {
+ result[emotionKey] = emotions.map((emotion) => emotion.id);
+ }
+
return result;
}
@@ -362,6 +396,7 @@ export {
ContextualBrandSafetyOf,
ContextualMaxRiskLevel,
ContextualSentimentOf,
+ ContextualEmotionsOf,
};
export default ContextualSegments;
export type {
@@ -373,6 +408,8 @@ export type {
ContextualBrandSafety,
ContextualSentimentLabel,
ContextualSentiment,
+ ContextualEmotionLabel,
+ ContextualEmotion,
ContextualClassifications,
ContextualSegmentsResponse,
ContextualTargetingKeyValuesOptions,
diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts
index 5e9c9d11..6e083595 100644
--- a/lib/sdk.test.ts
+++ b/lib/sdk.test.ts
@@ -118,6 +118,10 @@ describe("Breaking change detection: if typescript complains or a test fails it'
new OptableSDK({ ...defaultConfig }).ctxSentiment();
});
+ test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: ctxEmotions", () => {
+ new OptableSDK({ ...defaultConfig }).ctxEmotions();
+ });
+
test("TEST SHOULD NEVER NEED TO BE UPDATED, UNLESS MAJOR VERSION UPDATE: profile", async () => {
await new OptableSDK({ ...defaultConfig }).profile({
propString: "",
@@ -596,6 +600,42 @@ describe("behavior testing of", () => {
expect(sdk.ctxTargetingKeyValues(undefined, { keywordKey: "", brandSafetyKey: "" })).toEqual({});
});
+ test("ctxEmotions reads the emotions cached by ctxSegments", async () => {
+ const emotions = [
+ { id: "joy", name: "Joy", score: 0.9 },
+ { id: "sadness", name: "Sadness", score: 0.7 },
+ ];
+ server.use(
+ http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => {
+ return HttpResponse.json({ classifications: { categories: [], keywords: [], emotions } }, { status: 200 });
+ })
+ );
+
+ const sdk = new OptableSDK({ ...defaultConfig });
+ await sdk.ctxSegments("https://example.com/article");
+
+ expect(sdk.ctxEmotions()).toEqual(emotions);
+ expect(sdk.ctxTargetingKeyValues(undefined, { keywordKey: "", brandSafetyKey: "" })).toEqual({
+ ctx_emotion: ["joy", "sadness"],
+ });
+ });
+
+ test("ctxEmotions returns an empty list when the DCN serves no emotions", async () => {
+ server.use(
+ http.post(`${TEST_BASE_URL}/v1beta1/contextual`, async () => {
+ return HttpResponse.json({ classifications: { categories: [], keywords: [] } }, { status: 200 });
+ })
+ );
+
+ const sdk = new OptableSDK({ ...defaultConfig });
+ expect(sdk.ctxEmotions()).toEqual([]);
+
+ await sdk.ctxSegments("https://example.com/article");
+
+ expect(sdk.ctxEmotions()).toEqual([]);
+ expect(sdk.ctxTargetingKeyValues(undefined, { keywordKey: "", brandSafetyKey: "" })).toEqual({});
+ });
+
// A DCN on an edge build predating brand-safety serving omits the field, the
// same answer it already gives for a node that does not run the classifier.
test("ctxBrandSafety reports nothing known when the DCN serves no brand safety", async () => {
diff --git a/lib/sdk.ts b/lib/sdk.ts
index 7b666e74..550f9e37 100644
--- a/lib/sdk.ts
+++ b/lib/sdk.ts
@@ -24,6 +24,8 @@ import { Profile } from "./edge/profile";
import {
ContextualBrandSafety,
ContextualBrandSafetyOf,
+ ContextualEmotion,
+ ContextualEmotionsOf,
ContextualMaxRiskLevel,
ContextualRiskTier,
ContextualSegments,
@@ -248,6 +250,12 @@ class OptableSDK {
return ContextualSentimentOf(this.contextualResponse);
}
+ // Emotions from the cached ctxSegments() response, highest score first; empty
+ // when the page has none.
+ ctxEmotions(): ContextualEmotion[] {
+ return ContextualEmotionsOf(this.contextualResponse);
+ }
+
async tokenize(id: string): Promise {
await this.init;
return Tokenize(this.dcn, id);
From 68dac9e9262988b2d44733cca8b05caf36bd802c Mon Sep 17 00:00:00 2001
From: Pier-Luc Caron St-Pierre
Date: Tue, 3 Nov 2026 09:43:08 -0500
Subject: [PATCH 2/3] contextual: skip malformed emotions and document empty
classifications
Co-Authored-By: Claude Opus 5.5
---
README.md | 4 ++--
.../nocookies/targeting/ctx_segments.html.tpl | 8 +++++---
demos/vanilla/targeting/ctx_segments.html.tpl | 8 +++++---
lib/edge/contextual_segments.test.ts | 13 +++++++++++++
lib/edge/contextual_segments.ts | 7 +++++--
lib/sdk.ts | 4 ++--
6 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index 1bb82a02..0307ea71 100644
--- a/README.md
+++ b/README.md
@@ -454,7 +454,7 @@ type ContextualSegmentsResponse = {
};
```
-`classifications` groups results by classification method; the DCN populates only the methods it has enabled.
+`classifications` groups results by classification method; the DCN populates only the methods it has enabled. `categories` and `keywords` are empty when the DCN never classified the page or does not run that classifier.
> **Note:** The requested URL must already have been classified by the DCN.
@@ -514,7 +514,7 @@ sdk.ctxEmotions();
// => [{ id: "joy", name: "Joy", score: 0.9 }, { id: "sadness", name: "Sadness", score: 0.7 }]
```
-`id` is one of the eight [Plutchik](https://en.wikipedia.org/wiki/Robert_Plutchik#Plutchik's_wheel_of_emotions) emotions: `joy`, `trust`, `fear`, `surprise`, `sadness`, `disgust`, `anger` or `anticipation`. `score` is from 0.0 to 1.0, and the list is ordered highest score first. It is multi-label: a page can evoke several emotions at once, opposites included (a bittersweet story can carry both `joy` and `sadness`).
+`id` is one of the eight [Plutchik](https://en.wikipedia.org/wiki/Robert_Plutchik#Plutchik's_wheel_of_emotions) emotions: `joy`, `trust`, `fear`, `surprise`, `sadness`, `disgust`, `anger` or `anticipation`. `score` is from 0.0 to 1.0, and the DCN serves the list highest score first. It is multi-label: a page can evoke several emotions at once, opposites included (a bittersweet story can carry both `joy` and `sadness`).
The list is empty when the page has no emotion: the DCN never classified it, does not run the emotion classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as served rather than filtering on `score` yourself.
diff --git a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl
index 950e52eb..cb4b9bc1 100644
--- a/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl
+++ b/demos/vanilla/nocookies/targeting/ctx_segments.html.tpl
@@ -187,6 +187,7 @@ optable.instance.ctxSegments("https://optable.co/");
+ Empty when the DCN never classified the page or does not run a taxonomy classifier.
keywords: free-form terms extracted from the page
@@ -203,6 +204,7 @@ optable.instance.ctxSegments("https://optable.co/");
+ Empty when the DCN never classified the page or does not extract keywords.
brandSafety: brand-safety assessment of the page
The categories and the risk tiers follow the
@@ -337,9 +339,9 @@ if (sentiment.scored && sentiment.id === "negative") {
- The list is multi-label and ordered highest score first: a page can evoke several emotions at once,
- opposites included. It is empty when the DCN never classified the page, does not run the emotion
- classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as
+ The list is multi-label, and the DCN serves it highest score first: a page can evoke several emotions
+ at once, opposites included. It is empty when the DCN never classified the page, does not run the
+ emotion classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as
served rather than filtering on the score yourself. ctxEmotions() reads it off the cached
response:
diff --git a/demos/vanilla/targeting/ctx_segments.html.tpl b/demos/vanilla/targeting/ctx_segments.html.tpl
index 959d9753..6ecee036 100644
--- a/demos/vanilla/targeting/ctx_segments.html.tpl
+++ b/demos/vanilla/targeting/ctx_segments.html.tpl
@@ -186,6 +186,7 @@ optable.instance.ctxSegments("https://optable.co/");
+ Empty when the DCN never classified the page or does not run a taxonomy classifier.
keywords: free-form terms extracted from the page
@@ -202,6 +203,7 @@ optable.instance.ctxSegments("https://optable.co/");
+ Empty when the DCN never classified the page or does not extract keywords.
brandSafety: brand-safety assessment of the page
The categories and the risk tiers follow the
@@ -336,9 +338,9 @@ if (sentiment.scored && sentiment.id === "negative") {
- The list is multi-label and ordered highest score first: a page can evoke several emotions at once,
- opposites included. It is empty when the DCN never classified the page, does not run the emotion
- classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as
+ The list is multi-label, and the DCN serves it highest score first: a page can evoke several emotions
+ at once, opposites included. It is empty when the DCN never classified the page, does not run the
+ emotion classifier, or nothing reached its cutoff. The DCN applies that cutoff before serving, so use the list as
served rather than filtering on the score yourself. ctxEmotions() reads it off the cached
response:
diff --git a/lib/edge/contextual_segments.test.ts b/lib/edge/contextual_segments.test.ts
index 82ed8dd9..3c287b51 100644
--- a/lib/edge/contextual_segments.test.ts
+++ b/lib/edge/contextual_segments.test.ts
@@ -444,6 +444,19 @@ describe("ContextualTargetingKeyValues emotion emission", () => {
expect(ContextualTargetingKeyValues(null, undefined, options)).toEqual({});
});
+ // A throw here would drop every key-value, categories and brand safety included.
+ test("skips entries without a string id", () => {
+ const malformed = emotions([null, { name: "Joy" }, { id: "trust", name: "Trust", score: 0.6 }] as never);
+
+ expect(ContextualTargetingKeyValues(malformed, undefined, { brandSafetyKey: "" })).toEqual({
+ iab_ct_3_1: ["53"],
+ ctx_emotion: ["trust"],
+ });
+ expect(
+ ContextualTargetingKeyValues(emotions([null, { name: "Joy" }] as never), undefined, { brandSafetyKey: "" })
+ ).toEqual({ iab_ct_3_1: ["53"] });
+ });
+
test("emits under a caller-provided key, and opts out on an empty key", () => {
expect(ContextualTargetingKeyValues(mixed, undefined, { brandSafetyKey: "", emotionKey: "mood" })).toEqual({
iab_ct_3_1: ["53"],
diff --git a/lib/edge/contextual_segments.ts b/lib/edge/contextual_segments.ts
index 76020c09..a3639099 100644
--- a/lib/edge/contextual_segments.ts
+++ b/lib/edge/contextual_segments.ts
@@ -369,8 +369,11 @@ function ContextualTargetingKeyValues(
// The ids only, for the same reason as sentiment.
const emotionKey = options?.emotionKey ?? DEFAULT_EMOTION_KEY;
const emotions = ContextualEmotionsOf(response);
- if (emotionKey && emotions.length > 0) {
- result[emotionKey] = emotions.map((emotion) => emotion.id);
+ if (emotionKey) {
+ const values = emotions.filter((emotion) => typeof emotion?.id === "string").map((emotion) => emotion.id);
+ if (values.length > 0) {
+ result[emotionKey] = values;
+ }
}
return result;
diff --git a/lib/sdk.ts b/lib/sdk.ts
index 550f9e37..90be49ee 100644
--- a/lib/sdk.ts
+++ b/lib/sdk.ts
@@ -250,8 +250,8 @@ class OptableSDK {
return ContextualSentimentOf(this.contextualResponse);
}
- // Emotions from the cached ctxSegments() response, highest score first; empty
- // when the page has none.
+ // Emotions from the cached ctxSegments() response, in the order the DCN serves
+ // them (highest score first); empty when the page has none.
ctxEmotions(): ContextualEmotion[] {
return ContextualEmotionsOf(this.contextualResponse);
}
From 24e763748febac8998e32568712965b171008233 Mon Sep 17 00:00:00 2001
From: Pier-Luc Caron St-Pierre
Date: Tue, 3 Nov 2026 15:34:51 -0500
Subject: [PATCH 3/3] contextual: skip malformed emotions in
ContextualEmotionsOf
Co-Authored-By: Claude Opus 5.5
---
lib/edge/contextual_segments.test.ts | 7 +++++++
lib/edge/contextual_segments.ts | 10 ++++------
2 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/lib/edge/contextual_segments.test.ts b/lib/edge/contextual_segments.test.ts
index 3c287b51..9f55a41b 100644
--- a/lib/edge/contextual_segments.test.ts
+++ b/lib/edge/contextual_segments.test.ts
@@ -406,6 +406,13 @@ describe("ContextualEmotionsOf", () => {
expect(ContextualEmotionsOf(response({ categories: [] }))).toEqual([]);
expect(ContextualEmotionsOf(null)).toEqual([]);
});
+
+ test("skips entries without a string id", () => {
+ const trust: ContextualEmotion = { id: "trust", name: "Trust", score: 0.6 };
+ const malformed = [null, { name: "Joy" }, trust] as never;
+
+ expect(ContextualEmotionsOf(response({ categories: [], emotions: malformed }))).toEqual([trust]);
+ });
});
describe("ContextualTargetingKeyValues emotion emission", () => {
diff --git a/lib/edge/contextual_segments.ts b/lib/edge/contextual_segments.ts
index a3639099..93a758ad 100644
--- a/lib/edge/contextual_segments.ts
+++ b/lib/edge/contextual_segments.ts
@@ -176,7 +176,8 @@ function ContextualSentimentOf(response: ContextualSegmentsResponse | null): Con
// predating emotion serving omits the field, which folds into an empty list
// like a node that does not run the classifier.
function ContextualEmotionsOf(response: ContextualSegmentsResponse | null): ContextualEmotion[] {
- return response?.classifications?.emotions ?? [];
+ const emotions = response?.classifications?.emotions ?? [];
+ return emotions.filter((emotion) => typeof emotion?.id === "string");
}
// Targeting key-values derived from a contextual segments response, suitable for
@@ -369,11 +370,8 @@ function ContextualTargetingKeyValues(
// The ids only, for the same reason as sentiment.
const emotionKey = options?.emotionKey ?? DEFAULT_EMOTION_KEY;
const emotions = ContextualEmotionsOf(response);
- if (emotionKey) {
- const values = emotions.filter((emotion) => typeof emotion?.id === "string").map((emotion) => emotion.id);
- if (values.length > 0) {
- result[emotionKey] = values;
- }
+ if (emotionKey && emotions.length > 0) {
+ result[emotionKey] = emotions.map((emotion) => emotion.id);
}
return result;