Skip to content

Commit 3a3341d

Browse files
anpeteclaude
andcommitted
feat(page-context): collect page-type signals (jsonLdType, ogType, lang)
Add an optional pageTypeSignals field to page-context collection, gated behind the includePageTypeSignals feature setting (off by default). It extracts schema.org JSON-LD @type, Open Graph og:type, and the declared page language, riding the existing collectionResult message so consumers (e.g. Duck.ai sidebar shortcuts) get cheap page-type hints with no extra round-trip and no LLM. - Export extractPageTypeSignals(document) for unit testing (mirrors domToMarkdown); JSON-LD parsed defensively (malformed/oversized skipped), @type collected as string or array and from @graph, deduped, casing preserved, capped. - Add jasmine unit specs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f4cce3 commit 3a3341d

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

injected/src/features/page-context.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,104 @@ function getLinkText(node, children, settings) {
211211
return href ? `[${trimmedContent}](${href})` : collapseWhitespace(children);
212212
}
213213

214+
/**
215+
* Extract cheap, normalized page-type signals from a document: schema.org JSON-LD `@type`,
216+
* Open Graph `og:type`, and the declared page language. Every field is best-effort and is empty
217+
* or null when the page doesn't expose it. Domain is intentionally omitted; consumers derive it
218+
* from the page URL that already accompanies the collected content.
219+
* @param {Document} document
220+
* @param {{ maxTypes?: number, maxBlockLength?: number }} [options]
221+
* @returns {{ jsonLdType: string[], ogType: string | null, lang: string }}
222+
*/
223+
export function extractPageTypeSignals(document, { maxTypes = 10, maxBlockLength = 100000 } = {}) {
224+
return {
225+
jsonLdType: extractJsonLdTypes(document, maxTypes, maxBlockLength),
226+
ogType: extractOgType(document),
227+
lang: extractLang(document),
228+
};
229+
}
230+
231+
/**
232+
* Collect deduped schema.org `@type` values from every JSON-LD block, preserving casing. Handles
233+
* `@type` as a string or array and entries nested under `@graph`. Malformed and oversized blocks
234+
* are skipped so a bad block never fails the whole collection.
235+
* @param {Document} document
236+
* @param {number} maxTypes
237+
* @param {number} maxBlockLength
238+
* @returns {string[]}
239+
*/
240+
function extractJsonLdTypes(document, maxTypes, maxBlockLength) {
241+
/** @type {string[]} */
242+
const types = [];
243+
const seen = new Set();
244+
/** @param {unknown} value */
245+
const add = (value) => {
246+
if (typeof value !== 'string') return;
247+
const type = value.trim();
248+
if (!type || seen.has(type)) return;
249+
seen.add(type);
250+
types.push(type);
251+
};
252+
253+
const blocks = document.querySelectorAll('script[type="application/ld+json"]');
254+
for (const block of blocks) {
255+
if (types.length >= maxTypes) break;
256+
const text = block.textContent || '';
257+
if (!text || text.length > maxBlockLength) continue;
258+
let data;
259+
try {
260+
data = JSON.parse(text);
261+
} catch (e) {
262+
continue;
263+
}
264+
collectJsonLdTypes(data, add);
265+
}
266+
267+
return types.slice(0, maxTypes);
268+
}
269+
270+
/**
271+
* Walk a parsed JSON-LD value (object, array, or `@graph` container) and report each `@type`.
272+
* @param {unknown} node
273+
* @param {(value: unknown) => void} add
274+
*/
275+
function collectJsonLdTypes(node, add) {
276+
if (Array.isArray(node)) {
277+
for (const item of node) collectJsonLdTypes(item, add);
278+
return;
279+
}
280+
if (!node || typeof node !== 'object') return;
281+
const obj = /** @type {Record<string, unknown>} */ (node);
282+
const type = obj['@type'];
283+
if (Array.isArray(type)) {
284+
for (const value of type) add(value);
285+
} else if (type != null) {
286+
add(type);
287+
}
288+
if (obj['@graph']) {
289+
collectJsonLdTypes(obj['@graph'], add);
290+
}
291+
}
292+
293+
/**
294+
* @param {Document} document
295+
* @returns {string | null}
296+
*/
297+
function extractOgType(document) {
298+
const meta = document.querySelector('meta[property="og:type"]');
299+
const content = meta?.getAttribute('content');
300+
const trimmed = typeof content === 'string' ? content.trim() : '';
301+
return trimmed || null;
302+
}
303+
304+
/**
305+
* @param {Document} document
306+
* @returns {string}
307+
*/
308+
function extractLang(document) {
309+
return (document.documentElement?.getAttribute('lang') || '').trim();
310+
}
311+
214312
export default class PageContext extends ContentFeature {
215313
/** @type {any} */
216314
#cachedContent = undefined;
@@ -492,6 +590,9 @@ export default class PageContext extends ContentFeature {
492590
if (this.getFeatureSettingEnabled('includeImages', 'disabled')) {
493591
content.images = this.getImages();
494592
}
593+
if (this.getFeatureSettingEnabled('includePageTypeSignals', 'disabled')) {
594+
content.pageTypeSignals = this.getPageTypeSignals();
595+
}
495596

496597
// Cache the result - setter handles timestamp and observer
497598
// Note: We only cache if content exists. Consider caching empty content too
@@ -620,6 +721,13 @@ export default class PageContext extends ContentFeature {
620721
return links;
621722
}
622723

724+
getPageTypeSignals() {
725+
return extractPageTypeSignals(document, {
726+
maxTypes: this.getFeatureSetting('maxPageTypeSignals') || 10,
727+
maxBlockLength: this.getFeatureSetting('maxJsonLdBlockLength') || 100000,
728+
});
729+
}
730+
623731
getImages() {
624732
const images = [];
625733
const imgSelector = this.getFeatureSetting('imgSelector') || 'img';
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { JSDOM } from 'jsdom';
2+
import { extractPageTypeSignals } from '../src/features/page-context.js';
3+
4+
/**
5+
* @param {string} html - full document HTML to parse.
6+
*/
7+
function signalsFor(html) {
8+
const dom = new JSDOM(html);
9+
return extractPageTypeSignals(dom.window.document);
10+
}
11+
12+
function scriptLd(obj) {
13+
return '<script type="application/ld+json">' + JSON.stringify(obj) + '</script>';
14+
}
15+
16+
function pageWithHead(headHtml) {
17+
return `<!DOCTYPE html><html><head>${headHtml}</head><body></body></html>`;
18+
}
19+
20+
describe('page-context.js - extractPageTypeSignals', () => {
21+
it('returns empty signals for a bare page', () => {
22+
const signals = signalsFor('<!DOCTYPE html><html><head></head><body></body></html>');
23+
expect(signals).toEqual({ jsonLdType: [], ogType: null, lang: '' });
24+
});
25+
26+
it('reads and trims og:type', () => {
27+
const signals = signalsFor(pageWithHead('<meta property="og:type" content=" video.other ">'));
28+
expect(signals.ogType).toBe('video.other');
29+
});
30+
31+
it('returns null og:type when the meta tag is absent', () => {
32+
expect(signalsFor(pageWithHead('')).ogType).toBe(null);
33+
});
34+
35+
it('reads the declared page language', () => {
36+
const signals = signalsFor('<!DOCTYPE html><html lang="en-US"><head></head><body></body></html>');
37+
expect(signals.lang).toBe('en-US');
38+
});
39+
40+
it('reads a string @type from JSON-LD', () => {
41+
expect(signalsFor(pageWithHead(scriptLd({ '@type': 'Recipe' }))).jsonLdType).toEqual(['Recipe']);
42+
});
43+
44+
it('reads an array @type from JSON-LD', () => {
45+
expect(signalsFor(pageWithHead(scriptLd({ '@type': ['Recipe', 'NewsArticle'] }))).jsonLdType).toEqual(['Recipe', 'NewsArticle']);
46+
});
47+
48+
it('walks @graph entries', () => {
49+
const head = scriptLd({ '@graph': [{ '@type': 'Product' }, { '@type': 'Offer' }] });
50+
expect(signalsFor(pageWithHead(head)).jsonLdType).toEqual(['Product', 'Offer']);
51+
});
52+
53+
it('dedupes across multiple blocks and preserves schema.org casing', () => {
54+
const head = scriptLd({ '@type': 'Recipe' }) + scriptLd({ '@type': ['Recipe', 'VideoObject'] });
55+
expect(signalsFor(pageWithHead(head)).jsonLdType).toEqual(['Recipe', 'VideoObject']);
56+
});
57+
58+
it('skips malformed JSON-LD blocks but keeps valid ones', () => {
59+
const head = '<script type="application/ld+json">{ not valid json </script>' + scriptLd({ '@type': 'Article' });
60+
expect(signalsFor(pageWithHead(head)).jsonLdType).toEqual(['Article']);
61+
});
62+
63+
it('ignores non-string @type values', () => {
64+
expect(signalsFor(pageWithHead(scriptLd({ '@type': ['Recipe', 123, null] }))).jsonLdType).toEqual(['Recipe']);
65+
});
66+
67+
it('caps the number of collected types', () => {
68+
const many = Array.from({ length: 20 }, (_, i) => ({ '@type': 'T' + i }));
69+
const dom = new JSDOM(pageWithHead(scriptLd(many)));
70+
const signals = extractPageTypeSignals(dom.window.document, { maxTypes: 3 });
71+
expect(signals.jsonLdType).toEqual(['T0', 'T1', 'T2']);
72+
});
73+
74+
it('skips oversized JSON-LD blocks', () => {
75+
const big = { '@type': 'Recipe', padding: 'x'.repeat(50) };
76+
const dom = new JSDOM(pageWithHead(scriptLd(big)));
77+
const signals = extractPageTypeSignals(dom.window.document, { maxBlockLength: 10 });
78+
expect(signals.jsonLdType).toEqual([]);
79+
});
80+
81+
it('collects all three signals from a realistic page (recipe with embedded video)', () => {
82+
const head =
83+
'<meta property="og:type" content="article">' +
84+
scriptLd({ '@type': ['Recipe', 'NewsArticle'] }) +
85+
scriptLd({ '@type': 'VideoObject' });
86+
const dom = new JSDOM(`<!DOCTYPE html><html lang="en"><head>${head}</head><body></body></html>`);
87+
expect(extractPageTypeSignals(dom.window.document)).toEqual({
88+
jsonLdType: ['Recipe', 'NewsArticle', 'VideoObject'],
89+
ogType: 'article',
90+
lang: 'en',
91+
});
92+
});
93+
});

0 commit comments

Comments
 (0)