diff --git a/src/controllers/elements.js b/src/controllers/elements.js index e251450552..836348c55f 100644 --- a/src/controllers/elements.js +++ b/src/controllers/elements.js @@ -1044,6 +1044,10 @@ export default function ElementsController(context, log, env) { projects = await service.getOwnedUrlProjects(workspaceId, { brandSemrushProjects }); } + // channel is intentionally NOT read/forwarded here — owned-urls always + // returns domain_type='Owned' rows by design, regardless of any channel + // param a caller sends (unlike cited-domains/domain-urls, which do filter + // by channel). const allUrls = await service.getOwnedUrls(workspaceId, { projects, model: query.model || query.platform, diff --git a/src/support/elements/constants.js b/src/support/elements/constants.js index 8b390f0b76..e9f945245a 100644 --- a/src/support/elements/constants.js +++ b/src/support/elements/constants.js @@ -61,3 +61,20 @@ export function resolveElementModel(value) { return ELEMENT_MODELS.includes(mapped) ? mapped : DEFAULT_ELEMENT_MODEL; } /* c8 ignore stop */ + +/** + * Normalizes a `channel`/content-type value for comparison against the Elements + * API's `domain_type` (e.g. `Other`, `Social`, `Owned`, `Earned`, + * `Benchmark Competitors`). Callers (UI, S2S) send snake_case (`benchmark_competitors`); + * Semrush returns Title Case with spaces. Trims, lowercases, and folds `_`/`-`/whitespace + * runs into a single space so both sides collapse to the same canonical form + * (`"benchmark_competitors"` and `"Benchmark Competitors"` both → `"benchmark competitors"`). + * + * @param {string} [value] - Raw channel value from a query param or element field. + * @returns {string} Canonical lowercase, space-separated form; `''` if not a string. + */ +export function normalizeChannel(value) { + return typeof value === 'string' + ? value.trim().toLowerCase().replace(/[\s_-]+/g, ' ') + : ''; +} diff --git a/src/support/elements/definitions/cited-domains.js b/src/support/elements/definitions/cited-domains.js index 7ae4502336..2132fd2473 100644 --- a/src/support/elements/definitions/cited-domains.js +++ b/src/support/elements/definitions/cited-domains.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { resolveElementModel } from '../constants.js'; +import { resolveElementModel, normalizeChannel } from '../constants.js'; // Legacy default window is a rolling 28 days (see defaultDateRange in // llmo-brand-presence.js). Kept inline here so this definition stays pure and does @@ -185,13 +185,12 @@ function mergeDomainRows(rowsByProject) { */ function paginateDomains(domainsIn, params = {}) { const { page, pageSize } = parsePagination(params); - const channel = typeof params.channel === 'string' ? params.channel.trim() : ''; + const wanted = normalizeChannel(params.channel); let domains = domainsIn; // `channel` = content-type filter, applied client-side (element ignores it server-side). - if (channel) { - const wanted = channel.toLowerCase(); - domains = domains.filter((d) => d.contentType.toLowerCase() === wanted); + if (wanted) { + domains = domains.filter((d) => normalizeChannel(d.contentType) === wanted); } domains = [...domains].sort((a, b) => b.totalCitations - a.totalCitations); diff --git a/src/support/elements/definitions/domain-urls.js b/src/support/elements/definitions/domain-urls.js index effd58f758..855dd8b92b 100644 --- a/src/support/elements/definitions/domain-urls.js +++ b/src/support/elements/definitions/domain-urls.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { resolveElementModel } from '../constants.js'; +import { resolveElementModel, normalizeChannel } from '../constants.js'; /* c8 ignore start -- LLMO-6160 POC endpoint; unit tests intentionally deferred */ @@ -128,7 +128,7 @@ function parsePagination({ page, pageSize } = {}) { export function transformDomainUrlsResponse(projectResults = [], params = {}) { const { page, pageSize } = parsePagination(params); const hostname = String(params.hostname ?? '').replace(/^www\./, '').toLowerCase(); - const channel = typeof params.channel === 'string' ? params.channel.trim() : ''; + const channel = normalizeChannel(params.channel); const byUrl = new Map(); for (const { region, stats } of projectResults) { @@ -177,8 +177,7 @@ export function transformDomainUrlsResponse(projectResults = [], params = {}) { // `channel` = content-type filter, applied client-side (element ignores it // server-side), mirroring cited-domains + the legacy RPC's `p_channel`. if (channel) { - const wanted = channel.toLowerCase(); - urls = urls.filter((u) => u.contentType.toLowerCase() === wanted); + urls = urls.filter((u) => normalizeChannel(u.contentType) === channel); } urls.sort((a, b) => b.citations - a.citations); diff --git a/src/support/elements/definitions/owned-urls.js b/src/support/elements/definitions/owned-urls.js index 7ea3ef5816..ad03889845 100644 --- a/src/support/elements/definitions/owned-urls.js +++ b/src/support/elements/definitions/owned-urls.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { resolveElementModel } from '../constants.js'; +import { resolveElementModel, normalizeChannel } from '../constants.js'; import { dateToIsoWeek } from '../week-utils.js'; /* c8 ignore start -- LLMO-6086 POC endpoint; unit tests intentionally deferred */ @@ -104,9 +104,13 @@ export function buildOwnedUrlsTrendPayload({ * urlId ('' — Semrush has no source_urls.id), products ([]), weeklyPromptsCited ([]). * * Only `domain_type='Owned'` rows are kept (client-side; the element ignores a - * server-side content-type filter). Returns the FULL owned list sorted by - * citations desc — the controller applies client-side pagination and then joins - * traffic for just the page's URLs (Semrush has no server-side pagination). + * server-side content-type filter), via {@link normalizeChannel} for a + * case/format-insensitive match. This endpoint is owned-only BY DESIGN — unlike + * cited-domains/domain-urls, it does NOT take a `channel` param; any such + * param on the request is ignored (see the controller). Returns the FULL owned + * list sorted by citations desc — the controller applies client-side pagination + * and then joins traffic for just the page's URLs (Semrush has no server-side + * pagination). * * @param {Array<{region?: string, stats: object, trend: object}>} projectResults * @returns {Array} Full owned-URL list, sorted by citations desc. @@ -127,13 +131,13 @@ export function transformOwnedUrlsResponse(projectResults = []) { for (const { region, stats, trend } of projectResults) { for (const row of (stats?.blocks?.data ?? [])) { - // Owned filter is client-side: the element ignores a server-side + // Channel filter is client-side: the element ignores a server-side // content-type filter (verified on cited-domains). if (!row || row.source == null) { // eslint-disable-next-line no-continue continue; } - if (String(row.domain_type ?? '').toLowerCase() !== 'owned') { + if (normalizeChannel(row.domain_type) !== 'owned') { // eslint-disable-next-line no-continue continue; } diff --git a/test/support/elements/constants.test.js b/test/support/elements/constants.test.js new file mode 100644 index 0000000000..06bd0de397 --- /dev/null +++ b/test/support/elements/constants.test.js @@ -0,0 +1,56 @@ +/* + * 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 { normalizeChannel } from '../../../src/support/elements/constants.js'; + +describe('normalizeChannel', () => { + it('returns an empty string for null', () => { + expect(normalizeChannel(null)).to.equal(''); + }); + + it('returns an empty string for undefined', () => { + expect(normalizeChannel(undefined)).to.equal(''); + }); + + it('returns an empty string for an empty string', () => { + expect(normalizeChannel('')).to.equal(''); + }); + + it('returns an empty string for a whitespace-only string', () => { + expect(normalizeChannel(' ')).to.equal(''); + }); + + it('lowercases and preserves a snake_case value as spaces', () => { + expect(normalizeChannel('benchmark_competitors')).to.equal('benchmark competitors'); + }); + + it('normalizes a Title Case, space-separated value to the same canonical form', () => { + expect(normalizeChannel('Benchmark Competitors')).to.equal('benchmark competitors'); + }); + + it('normalizes a hyphenated value the same way as snake_case', () => { + expect(normalizeChannel('ai-generated')).to.equal('ai generated'); + }); + + it('collapses repeated separators and trims surrounding whitespace', () => { + expect(normalizeChannel(' Benchmark__Competitors ')).to.equal('benchmark competitors'); + }); + + it('leaves a single-word value unaffected apart from casing', () => { + expect(normalizeChannel('Owned')).to.equal('owned'); + }); + + it('collapses repeated internal whitespace to match the snake_case form', () => { + expect(normalizeChannel('Benchmark Competitors')).to.equal('benchmark competitors'); + }); +});