Skip to content
Merged
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
90 changes: 59 additions & 31 deletions docs/index.html

Large diffs are not rendered by default.

33 changes: 21 additions & 12 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11670,18 +11670,19 @@ SerenityUrlInspectorFilterDimensions:
type: object
description: |
Filter dimensions for the Serenity (Semrush Elements-backed) URL Inspector
dashboard, sourced from the Semrush Brands, Markets, and Topics elements.

`brands`, `regions`, `topics`, `categories`, `page_intents`, `origins`, and
`tags` are always present (empty array when Semrush has no data). Any
additional Semrush tag prefix not already covered by the fixed dimensions
(e.g. a future `type__branded` tag) surfaces as an extra dynamic array
property keyed by that prefix — this is why the schema allows
`additionalProperties`. Prefixes that would collide with one of the fixed
keys above are routed into `tags` instead of clobbering that key. Bare
prefix declarations with no value (e.g. a lone `category` row) are ignored
entirely and never appear in any dimension.
required: [brands, regions, topics, categories, page_intents, origins, tags]
dashboard, sourced from the Semrush Brands, Markets, Topics, and Content
Types elements.

`brands`, `regions`, `topics`, `categories`, `page_intents`, `origins`,
`content_types`, and `tags` are always present (empty array when Semrush
has no data). Any additional Semrush tag prefix not already covered by the
fixed dimensions (e.g. a future `type__branded` tag) surfaces as an extra
dynamic array property keyed by that prefix — this is why the schema
allows `additionalProperties`. Prefixes that would collide with one of the
fixed keys above are routed into `tags` instead of clobbering that key.
Bare prefix declarations with no value (e.g. a lone `category` row) are
ignored entirely and never appear in any dimension.
required: [brands, regions, topics, categories, page_intents, origins, content_types, tags]
properties:
brands:
type: array
Expand Down Expand Up @@ -11727,6 +11728,14 @@ SerenityUrlInspectorFilterDimensions:
type: array
description: Distinct `source__`-prefixed tags. `id` is the original `source__`-prefixed tag value.
items: { $ref: '#/SerenityFilterDimensionItem' }
content_types:
type: array
description: |
Available content types (Semrush `domain_type` values, e.g. Owned,
Other, Social, Earned, Benchmark Competitors). `id` is derived from
`label` (lowercased, spaces replaced with underscores) — Semrush has
no stable content-type id.
items: { $ref: '#/SerenityFilterDimensionItem' }
tags:
type: array
description: |
Expand Down
28 changes: 22 additions & 6 deletions docs/openapi/serenity-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1063,18 +1063,20 @@ v2-serenity-url-inspector-filter-dimensions:
summary: Filter dimensions for the Serenity URL Inspector dashboard
description: |
Returns the option universe for the URL Inspector Brand/Region/Topic/
Category/Intent/Origin filter dropdowns, sourced from the Semrush Brands,
Markets, and Topics elements (not from Adobe Postgres). Unlike the
cascading `/brand-presence/filter-dimensions` endpoint, this read is not
itself filtered by any of the dimensions it returns — it defines what
those dropdowns can show.
Category/Intent/Origin/Content-Type filter dropdowns, sourced from the
Semrush Brands, Markets, Topics, and Content Types elements (not from
Adobe Postgres). Unlike the cascading `/brand-presence/filter-dimensions`
endpoint, this read is not itself filtered by any of the dimensions it
returns — it defines what those dropdowns can show.

`topics`/`categories`/`page_intents`/`origins` are derived from the same
underlying Topics element response, split apart by their `topic__`/
`category__`/`intent__`/`source__` tag prefix. Any other tag prefix Semrush
introduces later (e.g. `type__branded`) surfaces automatically as an extra
array property keyed by that prefix. Bare prefix declarations with no
value (e.g. a lone `category` row) are ignored — see
value (e.g. a lone `category` row) are ignored. `content_types` is
sourced from a separate Content Types element (Semrush `domain_type`
values) scoped to the same `model`/date window — see
`SerenityUrlInspectorFilterDimensions`.
operationId: listSerenityUrlInspectorFilterDimensions
security:
Expand Down Expand Up @@ -1116,6 +1118,20 @@ v2-serenity-url-inspector-filter-dimensions:
required: false
description: Semrush project UUID to scope the Topics element to a specific market.
schema: { type: string, format: uuid }
- name: startDate
in: query
required: false
description: |
ISO date (`YYYY-MM-DD`) scoping the Content Types element's date window.
Defaults to 28 days before `endDate`.
schema: { type: string, format: date }
- name: endDate
in: query
required: false
description: |
ISO date (`YYYY-MM-DD`) scoping the Content Types element's date window.
Defaults to today.
schema: { type: string, format: date }
responses:
'200':
description: Filter dimensions retrieved.
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ export default function ElementsController(context, log, env) {
* GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence
* /url-inspector/filter-dimensions
* Returns filter dimensions for the URL Inspector dashboard
* (brands, regions, topics, categories, page_intents, origins), scoped to
* (brands, regions, topics, categories, page_intents, origins, content_types), scoped to
* that single brand.
*/
const listUrlInspectorFilterDimensions = async (ctx) => {
Expand Down
83 changes: 83 additions & 0 deletions src/support/elements/definitions/content-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { resolveElementModel } from '../constants.js';

// Legacy default window is a rolling 28 days (see defaultDateRange in
// llmo-brand-presence.js / cited-domains.js). Kept inline here so this definition
// stays pure and does not import controller code.
const DEFAULT_WINDOW_DAYS = 28;

function defaultDateRange() {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - DEFAULT_WINDOW_DAYS);
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
};
}

/**
* Builds the payload for the Content Types filter-dimensions element (d1f9e6ec).
* Returns the available `domain_type` values (Owned/Other/Social/Earned/Benchmark
* Competitors) for the given model + date window — powers the Content Type filter
* dropdown.
*
* @param {object} [params]
* @param {string} [params.model] - AI model (Semrush engine name or SpaceCat/UI platform code).
* Translated to a Semrush model + validated via {@link resolveElementModel} (default search-gpt).
* @param {string} [params.platform] - Legacy alias for `model`; `model` takes precedence.
* @param {string} [params.startDate] - ISO date (YYYY-MM-DD). Defaults to 28 days ago.
* @param {string} [params.endDate] - ISO date (YYYY-MM-DD). Defaults to today.
*/
export function buildContentTypesPayload({
model, platform, startDate, endDate,
} = {}) {
const resolvedModel = resolveElementModel(model || platform);
const defaults = defaultDateRange();
const start = startDate || defaults.startDate;
const end = endDate || defaults.endDate;

return {
comparison_data_formatting: 'union',
filters: {
simple: {},
advanced: {
op: 'and',
filters: [
{ op: 'eq', val: resolvedModel, col: 'CBF_model' },
{ op: 'gte', val: start, col: 'CBF_date__start' },
{ op: 'lte', val: end, col: 'CBF_date__end' },
],
},
},
};
}

/**
* Transforms the raw Semrush Content Types element response into URL Inspector
* filter-dimension content types. `id` is derived from `label` (lowercased,
* spaces → underscores) — Semrush has no stable content-type ID.
*
* @param {object} raw - Raw response from the Elements API.
* @returns {Array<{id: string, label: string}>}
*/
export function transformContentTypesToFilterDimensions(raw) {
return (raw?.blocks?.value ?? [])
.map((item) => String(item.value ?? ''))
.filter((label) => label !== '')
.map((label) => ({
id: label.toLowerCase().replace(/\s+/g, '_'),
label,
}));
}
4 changes: 4 additions & 0 deletions src/support/elements/definitions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

export { buildBrandsPayload, transformBrandsToFilterDimensions } from './brands.js';
export { buildMarketsPayload, transformMarketsToFilterDimensions } from './markets.js';
export {
buildContentTypesPayload,
transformContentTypesToFilterDimensions,
} from './content-types.js';
export {
buildTopicsPayload,
transformTopicsForFilterDimensions,
Expand Down
1 change: 1 addition & 0 deletions src/support/elements/element-ids.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const ELEMENT_IDS = Object.freeze({
BRANDS: 'b178ce4e-6471-4430-9a32-8228ce72b2e6',
MARKETS: '478968a7-8851-4daf-83f7-2e8fb6185ddc',
TOPICS: 'ba3b19c1-22d4-460a-8dc3-1ff05c360852',
CONTENT_TYPES: 'd1f9e6ec-9128-4b1b-b767-d86f93f54237',
// Superseded by a new element with the same semantics but a shape aligned to
// Mentions/Visibility/Citations (CBF_ws_brand + CBF_model + optional
// CBF_project OR-list, all wrapped in their own `or` blocks) — see
Expand Down
10 changes: 9 additions & 1 deletion src/support/elements/elements-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
transformBrandsToFilterDimensions,
buildMarketsPayload,
transformMarketsToFilterDimensions,
buildContentTypesPayload,
transformContentTypesToFilterDimensions,
buildTopicsPayload,
transformTopicsForFilterDimensions,
transformCategoriesToFilterDimensions,
Expand Down Expand Up @@ -81,10 +83,15 @@ export function createElementsService(transport, log) {
spacecatBrands = [],
brandSemrushProjects = [],
) {
const [rawTopics, rawBrands, rawMarkets] = await Promise.all([
const [rawTopics, rawBrands, rawMarkets, rawContentTypes] = await Promise.all([
transport.fetchElement(workspaceId, ELEMENT_IDS.TOPICS, buildTopicsPayload(params)),
transport.fetchElement(workspaceId, ELEMENT_IDS.BRANDS, buildBrandsPayload(params)),
transport.fetchElement(workspaceId, ELEMENT_IDS.MARKETS, buildMarketsPayload({})),
transport.fetchElement(
workspaceId,
ELEMENT_IDS.CONTENT_TYPES,
buildContentTypesPayload(params),
),
]);
const result = {
brands: transformBrandsToFilterDimensions(rawBrands, spacecatBrands),
Expand All @@ -93,6 +100,7 @@ export function createElementsService(transport, log) {
categories: transformCategoriesToFilterDimensions(rawTopics),
page_intents: transformIntentsToFilterDimensions(rawTopics),
origins: transformOriginsToFilterDimensions(rawTopics),
content_types: transformContentTypesToFilterDimensions(rawContentTypes),
Comment thread
vivesing marked this conversation as resolved.
};
// Merge any tag types not covered above (e.g. `type:branded`) under their own
// prefix key, and plain prefix-less tags under `tags` — see
Expand Down
1 change: 1 addition & 0 deletions test/openapi-contract/serenity-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ const FIXTURES = {
categories: [],
page_intents: [],
origins: [],
content_types: [{ id: 'owned', label: 'Owned' }],
tags: [],
},
},
Expand Down
142 changes: 142 additions & 0 deletions test/support/elements/definitions/content-types.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { expect } from 'chai';
import {
buildContentTypesPayload,
transformContentTypesToFilterDimensions,
} from '../../../../src/support/elements/definitions/content-types.js';
import { DEFAULT_ELEMENT_MODEL } from '../../../../src/support/elements/constants.js';

describe('content-types definitions', () => {
describe('buildContentTypesPayload', () => {
it('uses the default model when no params are provided', () => {
const payload = buildContentTypesPayload();
expect(payload.filters.advanced.filters[0]).to.deep.equal({
op: 'eq',
val: DEFAULT_ELEMENT_MODEL,
col: 'CBF_model',
});
});

it('uses the default model when model is not in ELEMENT_MODELS', () => {
const payload = buildContentTypesPayload({ model: 'unknown-model' });
expect(payload.filters.advanced.filters[0].val).to.equal(DEFAULT_ELEMENT_MODEL);
});

it('uses the provided model when it is in ELEMENT_MODELS', () => {
const payload = buildContentTypesPayload({ model: 'perplexity' });
expect(payload.filters.advanced.filters[0].val).to.equal('perplexity');
});

it('translates a SpaceCat/UI platform code to the Semrush model', () => {
const payload = buildContentTypesPayload({ model: 'copilot' });
expect(payload.filters.advanced.filters[0].val).to.equal('microsoft-copilot');
});

it('accepts the platform alias and translates it', () => {
const payload = buildContentTypesPayload({ platform: 'openai' });
expect(payload.filters.advanced.filters[0].val).to.equal('chatgpt-paid');
});

it('uses the provided startDate and endDate', () => {
const payload = buildContentTypesPayload({ startDate: '2026-06-07', endDate: '2026-06-14' });
expect(payload.filters.advanced.filters[1]).to.deep.equal({
op: 'gte',
val: '2026-06-07',
col: 'CBF_date__start',
});
expect(payload.filters.advanced.filters[2]).to.deep.equal({
op: 'lte',
val: '2026-06-14',
col: 'CBF_date__end',
});
});

it('defaults to a rolling 28-day window when no dates are provided', () => {
const payload = buildContentTypesPayload();
const start = payload.filters.advanced.filters[1];
const end = payload.filters.advanced.filters[2];
expect(start.col).to.equal('CBF_date__start');
expect(end.col).to.equal('CBF_date__end');
const startMs = new Date(start.val).getTime();
const endMs = new Date(end.val).getTime();
const daysApart = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000));
expect(daysApart).to.equal(28);
});

it('sets comparison_data_formatting to union', () => {
const payload = buildContentTypesPayload();
expect(payload.comparison_data_formatting).to.equal('union');
});

it('uses AND operator in the advanced filter', () => {
const payload = buildContentTypesPayload();
expect(payload.filters.advanced.op).to.equal('and');
});

it('includes an empty simple filter object', () => {
const payload = buildContentTypesPayload();
expect(payload.filters.simple).to.deep.equal({});
});
});

describe('transformContentTypesToFilterDimensions', () => {
it('returns an empty array when raw is null', () => {
expect(transformContentTypesToFilterDimensions(null)).to.deep.equal([]);
});

it('returns an empty array when raw has no blocks', () => {
expect(transformContentTypesToFilterDimensions({})).to.deep.equal([]);
});

it('derives id from a single-word label', () => {
const raw = { blocks: { value: [{ value: 'Owned' }] } };
const [item] = transformContentTypesToFilterDimensions(raw);
expect(item).to.deep.equal({ id: 'owned', label: 'Owned' });
});

it('derives id from a multi-word label by replacing spaces with underscores', () => {
const raw = { blocks: { value: [{ value: 'Benchmark Competitors' }] } };
const [item] = transformContentTypesToFilterDimensions(raw);
expect(item).to.deep.equal({ id: 'benchmark_competitors', label: 'Benchmark Competitors' });
});

it('filters out entries with an empty/missing value', () => {
const raw = { blocks: { value: [{ value: '' }, { }, { value: 'Social' }] } };
const result = transformContentTypesToFilterDimensions(raw);
expect(result).to.deep.equal([{ id: 'social', label: 'Social' }]);
});

it('handles the full known set of content types, preserving element order', () => {
const raw = {
blocks: {
value: [
{ value: 'Other' },
{ value: 'Social' },
{ value: 'Earned' },
{ value: 'Owned' },
{ value: 'Benchmark Competitors' },
],
},
};
const result = transformContentTypesToFilterDimensions(raw);
expect(result).to.deep.equal([
{ id: 'other', label: 'Other' },
{ id: 'social', label: 'Social' },
{ id: 'earned', label: 'Earned' },
{ id: 'owned', label: 'Owned' },
{ id: 'benchmark_competitors', label: 'Benchmark Competitors' },
]);
});
});
});
Loading
Loading