Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/controllers/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/support/elements/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' ')
: '';
}
Comment thread
vivesing marked this conversation as resolved.
9 changes: 4 additions & 5 deletions src/support/elements/definitions/cited-domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 3 additions & 4 deletions src/support/elements/definitions/domain-urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 10 additions & 6 deletions src/support/elements/definitions/owned-urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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<object>} Full owned-URL list, sorted by citations desc.
Expand All @@ -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;
}
Expand Down
56 changes: 56 additions & 0 deletions test/support/elements/constants.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading