Add exact URL / subfolder / domain / subdomain research scopes (EVE-56) (#487)

This commit is contained in:
Ben Senescu 2026-08-14 21:52:32 -04:00 committed by GitHub
parent 3df66ad9ae
commit c8b3eb9b0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 2776 additions and 828 deletions

View File

@ -190,7 +190,7 @@ export async function openDomainOverview(page: Page, tab: DomainTab) {
const params = new URLSearchParams({ const params = new URLSearchParams({
domain: PRIMARY_TEST_DOMAIN, domain: PRIMARY_TEST_DOMAIN,
subdomains: "true", scope: "subdomains",
sort: "traffic", sort: "traffic",
order: "desc", order: "desc",
}); });

View File

@ -1,6 +1,12 @@
import { parseResearchTarget } from "@/shared/researchScope";
export function getFixtureOverview(domain: string) { export function getFixtureOverview(domain: string) {
const parsed = parseResearchTarget(domain);
const target = parsed.ok ? parsed.target : null;
return { return {
domain, domain: target?.hostname ?? domain,
scope: target?.scope ?? "domain",
displayTarget: target?.display ?? domain,
organicTraffic: 373, organicTraffic: 373,
organicKeywords: 307, organicKeywords: 307,
backlinks: null, backlinks: null,

View File

@ -5,6 +5,7 @@ import type {
BacklinksLookupInput, BacklinksLookupInput,
BacklinksTargetScope, BacklinksTargetScope,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { backlinksScopeParamSchema } from "@/types/schemas/backlinks";
import { loadLocalEnv, parseArgs } from "./cli-utils"; import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv(); loadLocalEnv();
@ -143,8 +144,11 @@ function parseScope(
value: string | undefined, value: string | undefined,
): BacklinksTargetScope | undefined { ): BacklinksTargetScope | undefined {
if (!value) return undefined; if (!value) return undefined;
if (value === "domain" || value === "page") return value; const parsed = backlinksScopeParamSchema.safeParse(value);
printUsageAndExit(`Invalid scope: ${value}. Expected domain or page.`); if (parsed.success) return parsed.data;
printUsageAndExit(
`Invalid scope: ${value}. Expected domain, subdomains, or exact_url.`,
);
} }
function parsePositiveInteger(value: string | undefined, fallback: number) { function parsePositiveInteger(value: string | undefined, fallback: number) {
@ -156,7 +160,7 @@ function parsePositiveInteger(value: string | undefined, fallback: number) {
function printUsageAndExit(message: string): never { function printUsageAndExit(message: string): never {
console.error(message); console.error(message);
console.error( console.error(
"Usage: pnpm billing:backlinks --target=example.com --confirmLive=true [--scope=domain|page] [--repeat=1] [--includeTabs=true|false] [--allowCi=true]", "Usage: pnpm billing:backlinks --target=example.com --confirmLive=true [--scope=domain|subdomains|exact_url] [--repeat=1] [--includeTabs=true|false] [--allowCi=true]",
); );
process.exit(1); process.exit(1);
} }

View File

@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import {
RESEARCH_SCOPES,
RESEARCH_SCOPE_DESCRIPTIONS,
RESEARCH_SCOPE_EXAMPLES,
RESEARCH_SCOPE_LABELS,
type ResearchScope,
} from "@/shared/researchScope";
type Props = {
value: ResearchScope;
onChange: (scope: ResearchScope) => void;
/** Greys the whole control (e.g. a brand-keyword lookup) with this reason. */
disabledReason?: string;
className?: string;
"aria-label"?: string;
};
/**
* The shared research-scope selector: Exact URL / Subfolder / Domain /
* Subdomains. A custom dropdown (not a native select) so each option can
* explain what it covers. Every research input that accepts a URL or domain
* renders this next to the input so scope is explicit instead of inferred.
*/
export function ResearchScopeSelect({
value,
onChange,
disabledReason,
className = "",
"aria-label": ariaLabel = "Research scope",
}: Props) {
const [open, setOpen] = useState(false);
const [activeScope, setActiveScope] = useState<ResearchScope>(value);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) setActiveScope(value);
}, [open, value]);
// Close on outside click so it behaves like the surrounding native selects.
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !containerRef.current?.contains(target)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
const select = (scope: ResearchScope) => {
onChange(scope);
setOpen(false);
};
const moveActive = (step: 1 | -1) => {
const currentIndex = RESEARCH_SCOPES.indexOf(activeScope);
const nextIndex = Math.min(
Math.max(currentIndex + step, 0),
RESEARCH_SCOPES.length - 1,
);
const next = RESEARCH_SCOPES[nextIndex];
if (next) setActiveScope(next);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (!open) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActive(1);
break;
case "ArrowUp":
event.preventDefault();
moveActive(-1);
break;
case "Enter":
case " ":
event.preventDefault();
select(activeScope);
break;
case "Escape":
event.preventDefault();
setOpen(false);
break;
}
};
return (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
className="select select-bordered flex w-full items-center justify-between gap-2 text-left font-normal"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabledReason != null}
title={disabledReason}
onClick={() => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
>
<span className="truncate">{RESEARCH_SCOPE_LABELS[value]}</span>
<ChevronDown className="size-4 shrink-0 text-base-content/60" />
</button>
{open ? (
<ul
role="listbox"
aria-label={ariaLabel}
className="menu absolute right-0 z-30 mt-2 w-72 flex-nowrap rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
{RESEARCH_SCOPES.map((scope) => {
const isSelected = scope === value;
return (
<li key={scope} role="option" aria-selected={isSelected}>
<button
type="button"
className={`w-full items-start ${scope === activeScope ? "menu-focus" : ""}`}
onClick={() => select(scope)}
onMouseEnter={() => setActiveScope(scope)}
>
<span className="flex-1">
<span className="block">
{RESEARCH_SCOPE_LABELS[scope]}
</span>
<span className="block text-xs text-base-content/60">
{RESEARCH_SCOPE_DESCRIPTIONS[scope]}
</span>
<span className="block font-mono text-xs text-base-content/40">
{RESEARCH_SCOPE_EXAMPLES[scope]}
</span>
</span>
{isSelected ? (
<Check className="mt-1 size-4 shrink-0 text-primary" />
) : null}
</button>
</li>
);
})}
</ul>
) : null}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type FormEvent } from "react"; import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { import {
@ -25,14 +25,26 @@ import {
parseCompetitorList, parseCompetitorList,
} from "@/types/schemas/ai-search"; } from "@/types/schemas/ai-search";
import { detectTarget } from "@/shared/targetDetection"; import { detectTarget } from "@/shared/targetDetection";
import {
parseResearchTarget,
toScopeSearchParam,
type ResearchScope,
} from "@/shared/researchScope";
type Props = { type Props = {
projectId: string; projectId: string;
initialQuery: string; initialQuery: string;
initialCompetitors: string[]; initialCompetitors: string[];
onSearchChange: (nextQuery: string, nextCompetitors: string[]) => void; initialScope: ResearchScope | undefined;
onSearchChange: (
nextQuery: string,
nextCompetitors: string[],
nextScope: ResearchScope | undefined,
) => void;
}; };
const KEYWORD_SCOPE_REASON = "Scopes apply to domain lookups";
const BRAND_LOOKUP_BULLETS = [ const BRAND_LOOKUP_BULLETS = [
{ {
icon: TrendingUp, icon: TrendingUp,
@ -63,10 +75,15 @@ function BrandLookupPageInner({
projectId, projectId,
initialQuery, initialQuery,
initialCompetitors, initialCompetitors,
initialScope,
onSearchChange, onSearchChange,
planGate, planGate,
}: Props & { planGate: HostedPlanGateState }) { }: Props & { planGate: HostedPlanGateState }) {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
// The user's explicit scope pick, or undefined to follow the input's default.
const [scopeChoice, setScopeChoice] = useState<ResearchScope | undefined>(
initialScope,
);
// Raw comma-separated competitor text; parsed into a deduped array on submit. // Raw comma-separated competitor text; parsed into a deduped array on submit.
const [competitorsInput, setCompetitorsInput] = useState( const [competitorsInput, setCompetitorsInput] = useState(
initialCompetitors.join(", "), initialCompetitors.join(", "),
@ -84,14 +101,36 @@ function BrandLookupPageInner({
// stable string key, since `initialCompetitors` is a fresh array each render. // stable string key, since `initialCompetitors` is a fresh array each render.
const competitorKey = initialCompetitors.join(","); const competitorKey = initialCompetitors.join(",");
// Scope only applies to domain/URL inputs. The pick always stays selectable
// — an invalid one (Subfolder without a path) errors on submit instead of
// the select greying out or changing under the user.
const scopeTarget = useMemo(() => {
if (detectTarget(query).type !== "domain") return null;
const parsed = parseResearchTarget(query);
return parsed.ok ? parsed.target : null;
}, [query]);
const selectedScope = scopeChoice ?? scopeTarget?.scope ?? "domain";
// Only grey the control once the input is clearly a brand keyword — an
// empty box shouldn't look disabled before the user has typed anything.
const scopeDisabledReason =
query.trim() !== "" && !scopeTarget ? KEYWORD_SCOPE_REASON : undefined;
const lookupQuery = useQuery({ const lookupQuery = useQuery({
queryKey: ["brand-lookup", projectId, trimmedInitialQuery, competitorKey], queryKey: [
"brand-lookup",
projectId,
trimmedInitialQuery,
competitorKey,
initialScope ?? "",
],
queryFn: () => queryFn: () =>
lookupBrand({ lookupBrand({
data: { data: {
projectId, projectId,
query: trimmedInitialQuery, query: trimmedInitialQuery,
competitors: initialCompetitors, competitors: initialCompetitors,
scope: initialScope,
locationCode: 2840, locationCode: 2840,
languageCode: "en", languageCode: "en",
}, },
@ -117,18 +156,20 @@ function BrandLookupPageInner({
const lastAddedKeyRef = useRef<string | null>(null); const lastAddedKeyRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hasActiveQuery || !lookupQuery.isSuccess) return; if (!hasActiveQuery || !lookupQuery.isSuccess) return;
const addedKey = `${trimmedInitialQuery}::${competitorKey}`; const addedKey = `${trimmedInitialQuery}::${competitorKey}::${initialScope ?? ""}`;
if (lastAddedKeyRef.current === addedKey) return; if (lastAddedKeyRef.current === addedKey) return;
lastAddedKeyRef.current = addedKey; lastAddedKeyRef.current = addedKey;
addSearch({ addSearch({
query: trimmedInitialQuery, query: trimmedInitialQuery,
competitors: competitorKey ? competitorKey.split(",") : [], competitors: competitorKey ? competitorKey.split(",") : [],
scope: initialScope,
}); });
}, [ }, [
hasActiveQuery, hasActiveQuery,
lookupQuery.isSuccess, lookupQuery.isSuccess,
trimmedInitialQuery, trimmedInitialQuery,
competitorKey, competitorKey,
initialScope,
addSearch, addSearch,
]); ]);
@ -176,8 +217,24 @@ function BrandLookupPageInner({
}); });
return; return;
} }
if (
scopeTarget &&
selectedScope === "subfolder" &&
scopeTarget.path === ""
) {
setValidationError({
field: "query",
message: "Add a path to use Subfolder (e.g. example.com/blog)",
});
return;
}
setValidationError(null); setValidationError(null);
onSearchChange(trimmed, competitors); // Keyword lookups never carry a scope; domain lookups omit it when it
// matches the query's implied default.
const explicitScope = scopeTarget
? toScopeSearchParam(trimmed, selectedScope)
: undefined;
onSearchChange(trimmed, competitors, explicitScope);
}; };
// The form inputs are reset whenever the URL `q`/`c` changes — including the // The form inputs are reset whenever the URL `q`/`c` changes — including the
@ -188,8 +245,9 @@ function BrandLookupPageInner({
useEffect(() => { useEffect(() => {
setQuery(initialQuery); setQuery(initialQuery);
setCompetitorsInput(competitorKey.split(",").join(", ")); setCompetitorsInput(competitorKey.split(",").join(", "));
setScopeChoice(initialScope);
setValidationError(null); setValidationError(null);
}, [initialQuery, competitorKey]); }, [initialQuery, competitorKey, initialScope]);
const isLoading = hasActiveQuery && lookupQuery.isPending; const isLoading = hasActiveQuery && lookupQuery.isPending;
const errorMessage = const errorMessage =
@ -222,6 +280,9 @@ function BrandLookupPageInner({
setQuery(next); setQuery(next);
if (validationError) setValidationError(null); if (validationError) setValidationError(null);
}} }}
scope={selectedScope}
onScopeChange={setScopeChoice}
scopeDisabledReason={scopeDisabledReason}
competitors={competitorsInput} competitors={competitorsInput}
onCompetitorsChange={(next) => { onCompetitorsChange={(next) => {
setCompetitorsInput(next); setCompetitorsInput(next);
@ -251,7 +312,7 @@ function BrandLookupPageInner({
from="/p/$projectId/brand-lookup" from="/p/$projectId/brand-lookup"
to="/p/$projectId/brand-lookup" to="/p/$projectId/brand-lookup"
params={{ projectId }} params={{ projectId }}
search={{ q: undefined, c: undefined }} search={{ q: undefined, c: undefined, scope: undefined }}
replace replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent" className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
> >

View File

@ -334,11 +334,17 @@ export function buildTopQueriesColumns({
]; ];
} }
export function TopPagesTable({ table }: { table: Table<TopPageRow> }) { export function TopPagesTable({
table,
emptyMessage = "No cited sources to show.",
}: {
table: Table<TopPageRow>;
emptyMessage?: string;
}) {
if (table.getRowModel().rows.length === 0) { if (table.getRowModel().rows.length === 0) {
return ( return (
<p className="p-6 text-center text-sm text-base-content/60"> <p className="p-6 text-center text-sm text-base-content/60">
No cited sources to show. {emptyMessage}
</p> </p>
); );
} }
@ -346,11 +352,17 @@ export function TopPagesTable({ table }: { table: Table<TopPageRow> }) {
return <BrandLookupTable table={table} urlLikeColumnId="url" />; return <BrandLookupTable table={table} urlLikeColumnId="url" />;
} }
export function TopQueriesTable({ table }: { table: Table<TopQueryRow> }) { export function TopQueriesTable({
table,
emptyMessage = "No matching queries found.",
}: {
table: Table<TopQueryRow>;
emptyMessage?: string;
}) {
if (table.getRowModel().rows.length === 0) { if (table.getRowModel().rows.length === 0) {
return ( return (
<p className="p-6 text-center text-sm text-base-content/60"> <p className="p-6 text-center text-sm text-base-content/60">
No matching queries found. {emptyMessage}
</p> </p>
); );
} }

View File

@ -60,8 +60,16 @@ export function CitationTabsCard({
]; ];
const showQueryPlatform = queryPlatforms.length > 1; const showQueryPlatform = queryPlatforms.length > 1;
const showPagePlatform = pagePlatforms.length > 1; const showPagePlatform = pagePlatforms.length > 1;
// Under a URL scope `resolvedTarget` carries the path; the "You" badge and
// the Prompt Explorer brand highlight both want the bare hostname.
const targetDomain = const targetDomain =
result.detectedTargetType === "domain" ? result.resolvedTarget : null; result.detectedTargetType === "domain"
? result.resolvedTarget.split("/")[0]
: null;
const brand = targetDomain ?? result.resolvedTarget;
// Page rows are already narrowed server-side; say so instead of implying the
// pre-scope "everything cited alongside the brand" set.
const isUrlScoped = result.aggregatesAreDomainLevel;
const filteredPages = useMemo( const filteredPages = useMemo(
() => filterTopPages(result.topPages, filters.pages.values), () => filterTopPages(result.topPages, filters.pages.values),
@ -78,18 +86,18 @@ export function CitationTabsCard({
showPlatform: showPagePlatform, showPlatform: showPagePlatform,
targetDomain, targetDomain,
projectId, projectId,
brand: result.resolvedTarget, brand,
}), }),
[showPagePlatform, targetDomain, projectId, result.resolvedTarget], [showPagePlatform, targetDomain, projectId, brand],
); );
const queriesColumns = useMemo( const queriesColumns = useMemo(
() => () =>
buildTopQueriesColumns({ buildTopQueriesColumns({
showPlatform: showQueryPlatform, showPlatform: showQueryPlatform,
projectId, projectId,
brand: result.resolvedTarget, brand,
}), }),
[showQueryPlatform, projectId, result.resolvedTarget], [showQueryPlatform, projectId, brand],
); );
const pagesTable = useAppTable({ const pagesTable = useAppTable({
@ -229,19 +237,21 @@ export function CitationTabsCard({
<span> <span>
{activeTab === "pages" ? ( {activeTab === "pages" ? (
<> <>
Pages cited alongside{" "} {isUrlScoped ? "Cited pages within " : "Pages cited alongside "}
<strong className="text-base-content/80"> <strong className="text-base-content/80">
{result.resolvedTarget} {result.resolvedTarget}
</strong>{" "} </strong>
in AI answers. Prompt examples come from the fetched sample. {isUrlScoped ? "." : " in AI answers."} Prompt examples come from
the fetched sample.
</> </>
) : ( ) : (
<> <>
Fetched sample of prompts whose AI answer cited{" "} Fetched sample of prompts whose AI answer cited{" "}
{isUrlScoped ? "a page within " : null}
<strong className="text-base-content/80"> <strong className="text-base-content/80">
{result.resolvedTarget} {result.resolvedTarget}
</strong>{" "} </strong>
in its text or sources. {isUrlScoped ? "." : " in its text or sources."}
</> </>
)} )}
</span> </span>
@ -260,9 +270,26 @@ export function CitationTabsCard({
) : null} ) : null}
{activeTab === "pages" ? ( {activeTab === "pages" ? (
<TopPagesTable table={pagesTable} /> <TopPagesTable
table={pagesTable}
// The provider only returns the domain's top cited pages, so a URL
// scope can filter every sampled row away without meaning zero
// citations exist for that section.
emptyMessage={
isUrlScoped
? `None of this domain's top cited pages fall under ${result.resolvedTarget}. Broaden the scope to see domain-level citations.`
: undefined
}
/>
) : ( ) : (
<TopQueriesTable table={queriesTable} /> <TopQueriesTable
table={queriesTable}
emptyMessage={
isUrlScoped
? `No sampled prompts cited a page under ${result.resolvedTarget}. Broaden the scope to see domain-level prompts.`
: undefined
}
/>
)} )}
</section> </section>
); );

View File

@ -5,6 +5,7 @@ import {
SearchHistorySection, SearchHistorySection,
} from "@/client/features/ai-search/components/SearchHistorySection"; } from "@/client/features/ai-search/components/SearchHistorySection";
import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory"; import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory";
import { RESEARCH_SCOPE_LABELS } from "@/shared/researchScope";
type Props = { type Props = {
projectId: string; projectId: string;
@ -31,6 +32,7 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
item.competitors.length > 0 item.competitors.length > 0
? item.competitors.join(",") ? item.competitors.join(",")
: undefined, : undefined,
scope: item.scope,
}} }}
replace replace
className={HISTORY_ITEM_LINK_CLASS} className={HISTORY_ITEM_LINK_CLASS}
@ -40,7 +42,16 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
)} )}
renderItem={(item) => ( renderItem={(item) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-base-content">{item.query}</p> <p className="flex items-center gap-2 truncate font-medium text-base-content">
{item.query}
{/* Only non-default scopes are stored, so this badge always adds
information the query string doesn't already carry. */}
{item.scope ? (
<span className="badge badge-ghost badge-sm shrink-0">
{RESEARCH_SCOPE_LABELS[item.scope]}
</span>
) : null}
</p>
{item.competitors.length > 0 ? ( {item.competitors.length > 0 ? (
<p className="truncate text-xs text-base-content/50"> <p className="truncate text-xs text-base-content/50">
vs {item.competitors.join(", ")} vs {item.competitors.join(", ")}

View File

@ -8,6 +8,7 @@ import {
PLATFORM_DOT_CLASS, PLATFORM_DOT_CLASS,
} from "@/client/features/ai-search/platformLabels"; } from "@/client/features/ai-search/platformLabels";
import type { BrandLookupResult } from "@/types/schemas/ai-search"; import type { BrandLookupResult } from "@/types/schemas/ai-search";
import { RESEARCH_SCOPE_LABELS } from "@/shared/researchScope";
type Props = { type Props = {
result: BrandLookupResult; result: BrandLookupResult;
@ -17,6 +18,24 @@ type Props = {
type PlatformRow = BrandLookupResult["perPlatform"][number]; type PlatformRow = BrandLookupResult["perPlatform"][number];
type MetricKey = "mentions" | "aiSearchVolume"; type MetricKey = "mentions" | "aiSearchVolume";
const DOMAIN_LEVEL_TIP =
"AI search providers report mentions per domain, not per page. This number covers the whole domain — the cited pages below are limited to your scope.";
/**
* Marks a metric that could not be narrowed to a URL scope, so a page-scoped
* lookup never reads as if the number belonged to that page.
*/
function DomainLevelBadge() {
return (
<span
className="tooltip badge badge-ghost badge-sm shrink-0 normal-case"
data-tip={DOMAIN_LEVEL_TIP}
>
Domain-level
</span>
);
}
export function BrandLookupResults({ result, projectId }: Props) { export function BrandLookupResults({ result, projectId }: Props) {
if (!result.hasData) { if (!result.hasData) {
const erroredPlatforms = result.perPlatform.filter( const erroredPlatforms = result.perPlatform.filter(
@ -71,7 +90,12 @@ export function BrandLookupResults({ result, projectId }: Props) {
> >
<StatsCard result={result} /> <StatsCard result={result} />
{hasTrendData ? <MentionTrendCard result={result} /> : null} {hasTrendData ? <MentionTrendCard result={result} /> : null}
{sov ? <BrandLookupShareOfVoice shareOfVoice={sov} /> : null} {sov ? (
<BrandLookupShareOfVoice
shareOfVoice={sov}
isDomainLevel={result.aggregatesAreDomainLevel}
/>
) : null}
</div> </div>
<CitationTabsCard result={result} projectId={projectId} /> <CitationTabsCard result={result} projectId={projectId} />
@ -89,6 +113,11 @@ function BrandHeader({ result }: { result: BrandLookupResult }) {
<span className="badge badge-ghost badge-sm"> <span className="badge badge-ghost badge-sm">
{result.detectedTargetType} {result.detectedTargetType}
</span> </span>
{result.scope ? (
<span className="badge badge-ghost badge-sm">
{RESEARCH_SCOPE_LABELS[result.scope]}
</span>
) : null}
</div> </div>
<p className="text-xs text-base-content/50"> <p className="text-xs text-base-content/50">
Updated {formatRelative(result.fetchedAt)} Updated {formatRelative(result.fetchedAt)}
@ -107,6 +136,7 @@ function StatsCard({ result }: { result: BrandLookupResult }) {
value={result.totalMentions} value={result.totalMentions}
perPlatform={result.perPlatform} perPlatform={result.perPlatform}
metric="mentions" metric="mentions"
isDomainLevel={result.aggregatesAreDomainLevel}
/> />
<StatBlock <StatBlock
label="AI search volume" label="AI search volume"
@ -114,6 +144,7 @@ function StatsCard({ result }: { result: BrandLookupResult }) {
value={result.totalAiSearchVolume} value={result.totalAiSearchVolume}
perPlatform={result.perPlatform} perPlatform={result.perPlatform}
metric="aiSearchVolume" metric="aiSearchVolume"
isDomainLevel={result.aggregatesAreDomainLevel}
/> />
</div> </div>
</section> </section>
@ -126,12 +157,14 @@ function StatBlock({
value, value,
perPlatform, perPlatform,
metric, metric,
isDomainLevel,
}: { }: {
label: string; label: string;
tooltip: string; tooltip: string;
value: number | null; value: number | null;
perPlatform: PlatformRow[]; perPlatform: PlatformRow[];
metric: MetricKey; metric: MetricKey;
isDomainLevel: boolean;
}) { }) {
return ( return (
<div className="flex flex-1 flex-col justify-center p-4"> <div className="flex flex-1 flex-col justify-center p-4">
@ -140,6 +173,7 @@ function StatBlock({
<span className="tooltip inline-flex normal-case" data-tip={tooltip}> <span className="tooltip inline-flex normal-case" data-tip={tooltip}>
<Info className="size-3 text-base-content/40" /> <Info className="size-3 text-base-content/40" />
</span> </span>
{isDomainLevel ? <DomainLevelBadge /> : null}
</p> </p>
<p className="mt-1 text-3xl font-semibold tabular-nums"> <p className="mt-1 text-3xl font-semibold tabular-nums">
{formatCount(value)} {formatCount(value)}
@ -191,10 +225,11 @@ function PlatformStatRow({
function MentionTrendCard({ result }: { result: BrandLookupResult }) { function MentionTrendCard({ result }: { result: BrandLookupResult }) {
return ( return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100"> <section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="border-b border-base-300 px-4 py-3"> <div className="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-3">
<h3 className="text-sm font-semibold"> <h3 className="text-sm font-semibold">
Mention trend (last 12 months) Mention trend (last 12 months)
</h3> </h3>
{result.aggregatesAreDomainLevel ? <DomainLevelBadge /> : null}
</div> </div>
<div className="p-4"> <div className="p-4">
<BrandLookupMentionTrendCard result={result} /> <BrandLookupMentionTrendCard result={result} />

View File

@ -2,11 +2,16 @@ import type { FormEvent } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { applyBillingMarkupUsd } from "@/shared/billing"; import { applyBillingMarkupUsd } from "@/shared/billing";
import { ResearchScopeSelect } from "@/client/components/ResearchScopeSelect";
import type { ResearchScope } from "@/shared/researchScope";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
type Props = { type Props = {
query: string; query: string;
onQueryChange: (next: string) => void; onQueryChange: (next: string) => void;
scope: ResearchScope;
onScopeChange: (next: ResearchScope) => void;
scopeDisabledReason: string | undefined;
competitors: string; competitors: string;
onCompetitorsChange: (next: string) => void; onCompetitorsChange: (next: string) => void;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
@ -42,6 +47,9 @@ const BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD = markup(
export function BrandLookupSearchCard({ export function BrandLookupSearchCard({
query, query,
onQueryChange, onQueryChange,
scope,
onScopeChange,
scopeDisabledReason,
competitors, competitors,
onCompetitorsChange, onCompetitorsChange,
onSubmit, onSubmit,
@ -79,6 +87,12 @@ export function BrandLookupSearchCard({
/> />
</label> </label>
<ResearchScopeSelect
value={scope}
onChange={onScopeChange}
disabledReason={scopeDisabledReason}
/>
<button <button
type="submit" type="submit"
className="btn btn-primary shrink-0 px-6" className="btn btn-primary shrink-0 px-6"

View File

@ -15,8 +15,11 @@ type ShareEntry = ShareOfVoice["entries"][number];
*/ */
export function BrandLookupShareOfVoice({ export function BrandLookupShareOfVoice({
shareOfVoice, shareOfVoice,
isDomainLevel,
}: { }: {
shareOfVoice: ShareOfVoice; shareOfVoice: ShareOfVoice;
/** True under a URL scope: SoV always compares whole domains. */
isDomainLevel: boolean;
}) { }) {
const target = shareOfVoice.entries.find((entry) => entry.isTarget) ?? null; const target = shareOfVoice.entries.find((entry) => entry.isTarget) ?? null;
const maxPct = Math.max( const maxPct = Math.max(
@ -27,7 +30,17 @@ export function BrandLookupShareOfVoice({
return ( return (
<section className="flex h-full flex-col overflow-hidden rounded-xl border border-base-300 bg-base-100"> <section className="flex h-full flex-col overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-baseline justify-between gap-2 border-b border-base-300 px-4 py-3"> <div className="flex items-baseline justify-between gap-2 border-b border-base-300 px-4 py-3">
<h3 className="text-sm font-semibold">Share of Voice</h3> <h3 className="flex items-center gap-2 text-sm font-semibold">
Share of Voice
{isDomainLevel ? (
<span
className="tooltip badge badge-ghost badge-sm shrink-0 font-normal"
data-tip="Share of Voice compares whole domains — it is not narrowed to the page or folder you searched."
>
Domain-level
</span>
) : null}
</h3>
{target ? ( {target ? (
<span className="text-xs text-base-content/50"> <span className="text-xs text-base-content/50">
<span className="font-medium text-base-content/80"> <span className="font-medium text-base-content/80">

View File

@ -19,10 +19,13 @@ export function BacklinksFilterPanel({
activeTab, activeTab,
filters, filters,
onApplied, onApplied,
maxConditions,
}: { }: {
activeTab: BacklinksTab; activeTab: BacklinksTab;
filters: BacklinksFiltersState; filters: BacklinksFiltersState;
onApplied: () => void; onApplied: () => void;
/** Scope filters can consume part of the DataForSEO condition budget. */
maxConditions?: number;
}) { }) {
if (activeTab === "backlinks") { if (activeTab === "backlinks") {
const state = filters.backlinks; const state = filters.backlinks;
@ -34,6 +37,7 @@ export function BacklinksFilterPanel({
fields={BACKLINKS_FILTER_FIELDS} fields={BACKLINKS_FILTER_FIELDS}
activeFilterCount={state.activeFilterCount} activeFilterCount={state.activeFilterCount}
countConditions={countFilterConditions} countConditions={countFilterConditions}
maxConditions={maxConditions}
textFields={[ textFields={[
{ {
key: "include", key: "include",
@ -89,6 +93,7 @@ export function BacklinksFilterPanel({
fields={REFERRING_DOMAINS_FILTER_FIELDS} fields={REFERRING_DOMAINS_FILTER_FIELDS}
activeFilterCount={state.activeFilterCount} activeFilterCount={state.activeFilterCount}
countConditions={countFilterConditions} countConditions={countFilterConditions}
maxConditions={maxConditions}
textFields={[ textFields={[
{ {
key: "include", key: "include",
@ -136,6 +141,7 @@ export function BacklinksFilterPanel({
fields={TOP_PAGES_FILTER_FIELDS} fields={TOP_PAGES_FILTER_FIELDS}
activeFilterCount={state.activeFilterCount} activeFilterCount={state.activeFilterCount}
countConditions={countFilterConditions} countConditions={countFilterConditions}
maxConditions={maxConditions}
textFields={[ textFields={[
{ {
key: "include", key: "include",

View File

@ -1,6 +1,8 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Clock, History, Link2, X } from "lucide-react"; import { Clock, History, Link2, X } from "lucide-react";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory"; import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
import { RESEARCH_SCOPE_LABELS } from "@/shared/researchScope";
import { toScopeSearchParam } from "@/shared/researchScope";
type Props = { type Props = {
projectId: string; projectId: string;
@ -53,7 +55,7 @@ export function BacklinksHistorySection({
search={(prev) => ({ search={(prev) => ({
...prev, ...prev,
target: item.target, target: item.target,
scope: item.scope, scope: toScopeSearchParam(item.target, item.scope),
tab: undefined, tab: undefined,
page: undefined, page: undefined,
sort: undefined, sort: undefined,
@ -68,7 +70,7 @@ export function BacklinksHistorySection({
{item.target} {item.target}
</p> </p>
<p className="text-sm text-base-content/60 truncate"> <p className="text-sm text-base-content/60 truncate">
{item.scope === "domain" ? "Site-wide" : "Exact page"} {RESEARCH_SCOPE_LABELS[item.scope]}
</p> </p>
</div> </div>
</Link> </Link>

View File

@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { RESEARCH_SCOPE_LABELS } from "@/shared/researchScope";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { import {
BacklinksNewLostChart, BacklinksNewLostChart,
@ -42,18 +43,33 @@ export function BacklinksOverviewPanels({
</Link> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65"> <div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span> <span className="badge badge-outline">
{RESEARCH_SCOPE_LABELS[data.scope]}
</span>
<span>Target: {data.displayTarget}</span> <span>Target: {data.displayTarget}</span>
<span>-</span> <span>-</span>
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span> <span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
{/* history/live can't exclude subdomains, so say so rather than imply
the charts match the domain-scoped totals. */}
{data.scope === "domain" ? (
<span>- Trends include subdomains</span>
) : null}
</div> </div>
<OverviewGrid data={data} summaryStats={summaryStats} /> <OverviewGrid data={data} summaryStats={summaryStats} />
{data.scope === "page" ? ( {data.scope === "exact_url" ? (
<div className="alert alert-info"> <div className="alert alert-info">
<span> <span>
Showing backlinks for this exact page. Enter a bare domain for Showing backlinks for this exact page. Switch the scope to Domain or
site-wide results. Trend charts are only shown for domain-level Subdomains for site-wide results trend charts need one of those.
lookups. </span>
</div>
) : null}
{data.scope === "subfolder" ? (
<div className="alert alert-info">
<span>
Showing backlinks pointing into this subfolder. Counts come from
filtered backlink totals; rank, trends, and the referring-domains
breakdown need Domain or Subdomains scope.
</span> </span>
</div> </div>
) : null} ) : null}
@ -68,7 +84,8 @@ function OverviewGrid({
data: BacklinksOverviewData; data: BacklinksOverviewData;
summaryStats: SummaryStat[]; summaryStats: SummaryStat[];
}) { }) {
const domainScope = data.scope === "domain"; // Trend charts need history/live, which only takes a whole hostname.
const domainScope = data.scope === "domain" || data.scope === "subdomains";
return ( return (
<div <div
@ -87,7 +104,8 @@ function SummaryStatsGrid({
data: BacklinksOverviewData; data: BacklinksOverviewData;
summaryStats: SummaryStat[]; summaryStats: SummaryStat[];
}) { }) {
const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`; const hasTrendPanels = data.scope === "domain" || data.scope === "subdomains";
const cardClassName = `card bg-base-100 border border-base-300 ${hasTrendPanels ? "md:col-span-2 xl:col-span-1" : ""}`;
return ( return (
<div className={cardClassName}> <div className={cardClassName}>

View File

@ -155,6 +155,7 @@ export function BacklinksBody({
<BacklinksResultsCard <BacklinksResultsCard
projectId={projectId} projectId={projectId}
activeTab={searchState.tab} activeTab={searchState.tab}
scope={searchState.scope}
tabRows={tabRows} tabRows={tabRows}
filters={filters} filters={filters}
sorting={sorting} sorting={sorting}

View File

@ -23,6 +23,11 @@ import {
BACKLINKS_PAGE_SIZES, BACKLINKS_PAGE_SIZES,
type BacklinksTab, type BacklinksTab,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
import {
BACKLINKS_SUBFOLDER_FILTER_CONDITIONS,
type ResearchScope,
} from "@/shared/researchScope";
const BACKLINKS_RESULTS_TABS: Array<{ const BACKLINKS_RESULTS_TABS: Array<{
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
@ -36,6 +41,7 @@ const BACKLINKS_RESULTS_TABS: Array<{
export function BacklinksResultsCard({ export function BacklinksResultsCard({
projectId, projectId,
activeTab, activeTab,
scope,
tabRows, tabRows,
filters, filters,
sorting, sorting,
@ -53,6 +59,7 @@ export function BacklinksResultsCard({
}: { }: {
projectId: string; projectId: string;
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
scope: ResearchScope;
tabRows: BacklinksTabRows; tabRows: BacklinksTabRows;
filters: BacklinksFiltersState; filters: BacklinksFiltersState;
sorting: SortingState; sorting: SortingState;
@ -107,7 +114,10 @@ export function BacklinksResultsCard({
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div className="space-y-2"> <div className="space-y-2">
<div role="tablist" className="tabs tabs-border w-fit"> <div role="tablist" className="tabs tabs-border w-fit">
{BACKLINKS_RESULTS_TABS.map(({ label, tab }) => ( {BACKLINKS_RESULTS_TABS.filter(
// Referring domains can't be filtered to a path prefix.
({ tab }) => !(scope === "subfolder" && tab === "domains"),
).map(({ label, tab }) => (
<TabLink <TabLink
key={tab} key={tab}
activeTab={activeTab} activeTab={activeTab}
@ -188,6 +198,15 @@ export function BacklinksResultsCard({
activeTab={activeTab} activeTab={activeTab}
filters={filters} filters={filters}
onApplied={() => onPageChange(1)} onApplied={() => onPageChange(1)}
// The subfolder url-prefix group (and, on the backlinks tab, the
// server-appended spam condition) shares the 8-condition budget.
maxConditions={
scope === "subfolder"
? MAX_DATAFORSEO_FILTER_CONDITIONS -
BACKLINKS_SUBFOLDER_FILTER_CONDITIONS -
(activeTab === "pages" ? 0 : 1)
: undefined
}
/> />
) : null} ) : null}

View File

@ -7,17 +7,19 @@ import {
getFormError, getFormError,
shouldValidateFieldOnChange, shouldValidateFieldOnChange,
} from "@/client/lib/forms"; } from "@/client/lib/forms";
import type { BacklinksSearchState } from "./backlinksPageTypes"; import { ResearchScopeSelect } from "@/client/components/ResearchScopeSelect";
import { import {
inferBacklinksSearchScopeFromTarget, defaultScopeForInput,
resolveBacklinksSearchScope, parseResearchTarget,
} from "./backlinksSearchScope"; } from "@/shared/researchScope";
import type { BacklinksSearchState } from "./backlinksPageTypes";
type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">; type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
function getBacklinksValidationErrors( function getBacklinksValidationErrors(
value: SearchDraft, value: SearchDraft,
shouldValidateUntouchedField: boolean, shouldValidateUntouchedField: boolean,
validateFormat = false,
) { ) {
if (!value.target.trim()) { if (!value.target.trim()) {
if (!shouldValidateUntouchedField) { if (!shouldValidateUntouchedField) {
@ -31,6 +33,15 @@ function getBacklinksValidationErrors(
}); });
} }
if (validateFormat) {
const parsed = parseResearchTarget(value.target, value.scope);
if (!parsed.ok) {
return createFormValidationErrors({
fields: { target: parsed.message },
});
}
}
return null; return null;
} }
@ -52,21 +63,10 @@ export function BacklinksSearchCard({
value, value,
shouldValidateFieldOnChange(formApi, "target"), shouldValidateFieldOnChange(formApi, "target"),
), ),
onSubmit: ({ value }) => getBacklinksValidationErrors(value, true), onSubmit: ({ value }) => getBacklinksValidationErrors(value, true, true),
}, },
onSubmit: ({ value }) => { onSubmit: ({ value }) => {
const target = value.target.trim(); onSubmit({ ...value, target: value.target.trim() });
const scope = resolveBacklinksSearchScope({
target,
selectedScope: value.scope,
userSelectedScope,
});
onSubmit({
...value,
target,
scope,
});
}, },
}); });
@ -105,7 +105,7 @@ export function BacklinksSearchCard({
if (!userSelectedScope) { if (!userSelectedScope) {
form.setFieldValue( form.setFieldValue(
"scope", "scope",
inferBacklinksSearchScopeFromTarget(nextTarget), defaultScopeForInput(nextTarget),
); );
} }
}} }}
@ -115,6 +115,18 @@ export function BacklinksSearchCard({
}} }}
</form.Field> </form.Field>
<form.Field name="scope">
{(field) => (
<ResearchScopeSelect
value={field.state.value}
onChange={(scope) => {
setUserSelectedScope(true);
field.handleChange(scope);
}}
/>
)}
</form.Field>
<form.Subscribe selector={(state) => state.isSubmitting}> <form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => ( {(isSubmitting) => (
<button <button
@ -147,35 +159,6 @@ export function BacklinksSearchCard({
) : null; ) : null;
}} }}
</form.Subscribe> </form.Subscribe>
<div className="flex items-center gap-1">
<form.Field name="scope">
{(field) => (
<>
<button
type="button"
className={`btn btn-xs ${field.state.value === "domain" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
setUserSelectedScope(true);
field.handleChange("domain");
}}
>
Site-wide
</button>
<button
type="button"
className={`btn btn-xs ${field.state.value === "page" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
setUserSelectedScope(true);
field.handleChange("page");
}}
>
Exact page
</button>
</>
)}
</form.Field>
</div>
</div> </div>
</form> </form>

View File

@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getPersistedBacklinksSearchScope,
inferBacklinksSearchScopeFromTarget,
resolveBacklinksSearchScope,
} from "./backlinksSearchScope";
describe("inferBacklinksSearchScopeFromTarget", () => {
it("treats bare hostnames as domain lookups", () => {
expect(inferBacklinksSearchScopeFromTarget("example.com")).toBe("domain");
});
it("treats path-based targets without a protocol as page lookups", () => {
expect(inferBacklinksSearchScopeFromTarget("example.com/pricing")).toBe(
"page",
);
});
it("treats root urls with explicit protocol as domain lookups", () => {
expect(inferBacklinksSearchScopeFromTarget("https://example.com/")).toBe(
"domain",
);
});
it("treats explicit urls with a path as page lookups", () => {
expect(
inferBacklinksSearchScopeFromTarget("https://example.com/pricing"),
).toBe("page");
});
it("uses inferred scope until the user overrides it", () => {
expect(
resolveBacklinksSearchScope({
target: "example.com/pricing",
selectedScope: "domain",
userSelectedScope: false,
}),
).toBe("page");
});
it("preserves a manual scope override", () => {
expect(
resolveBacklinksSearchScope({
target: "https://example.com/pricing?utm_source=newsletter",
selectedScope: "domain",
userSelectedScope: true,
}),
).toBe("domain");
});
it("omits persisted scope when it matches the inferred target scope", () => {
expect(
getPersistedBacklinksSearchScope("example.com/pricing", "page"),
).toBe(undefined);
});
it("persists explicit scope overrides", () => {
expect(
getPersistedBacklinksSearchScope(
"https://example.com/pricing?utm_source=newsletter",
"domain",
),
).toBe("domain");
});
});

View File

@ -1,46 +0,0 @@
import type { BacklinksTargetScope } from "@/types/schemas/backlinks";
export function inferBacklinksSearchScopeFromTarget(
target: string,
): BacklinksTargetScope {
const trimmed = target.trim();
if (!trimmed) {
return "domain";
}
const hasExplicitProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed);
try {
const parsed = new URL(
hasExplicitProtocol ? trimmed : `https://${trimmed}`,
);
return parsed.pathname !== "/" ? "page" : "domain";
} catch {
return "domain";
}
}
export function resolveBacklinksSearchScope({
target,
selectedScope,
userSelectedScope,
}: {
target: string;
selectedScope: BacklinksTargetScope;
userSelectedScope: boolean;
}): BacklinksTargetScope {
if (userSelectedScope) {
return selectedScope;
}
return inferBacklinksSearchScopeFromTarget(target);
}
export function getPersistedBacklinksSearchScope(
target: string,
scope: BacklinksTargetScope,
): BacklinksTargetScope | undefined {
return scope === inferBacklinksSearchScopeFromTarget(target)
? undefined
: scope;
}

View File

@ -27,7 +27,7 @@ import {
toTopPagesFiltersPayload, toTopPagesFiltersPayload,
} from "./backlinksFilterTypes"; } from "./backlinksFilterTypes";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope"; import { toScopeSearchParam } from "@/shared/researchScope";
type UseBacklinksPageDataArgs = { type UseBacklinksPageDataArgs = {
projectId: string; projectId: string;
@ -231,7 +231,7 @@ export function navigateToBacklinksSearch(
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
target: values.target, target: values.target,
scope: getPersistedBacklinksSearchScope(values.target, values.scope), scope: toScopeSearchParam(values.target, values.scope),
tab: undefined, tab: undefined,
page: undefined, page: undefined,
sort: undefined, sort: undefined,

View File

@ -30,10 +30,17 @@ import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchT
import { import {
formatMetric, formatMetric,
getDefaultSortOrder, getDefaultSortOrder,
normalizeDomainTarget, getResearchInputPath,
toSortOrderSearchParam, toSortOrderSearchParam,
toSortSearchParam, toSortSearchParam,
} from "@/client/features/domain/utils"; } from "@/client/features/domain/utils";
import {
RESEARCH_SCOPE_LABELS,
defaultScopeForPath,
parseResearchTarget,
toScopeSearchParam,
type ResearchScope,
} from "@/shared/researchScope";
import { import {
createFormValidationErrors, createFormValidationErrors,
shouldValidateFieldOnChange, shouldValidateFieldOnChange,
@ -133,7 +140,8 @@ function getHistorySearchUpdate(
return { return {
...buildDomainFiltersClearSearchUpdate(), ...buildDomainFiltersClearSearchUpdate(),
domain: item.domain, domain: item.domain,
subdomains: item.subdomains ? undefined : false, scope: toScopeSearchParam(item.domain, item.scope),
subdomains: undefined,
sort: toSortSearchParam(item.sort), sort: toSortSearchParam(item.sort),
order: undefined, order: undefined,
tab: item.tab === "keywords" ? undefined : item.tab, tab: item.tab === "keywords" ? undefined : item.tab,
@ -144,7 +152,7 @@ function getHistorySearchUpdate(
function getSearchSubmitUpdate({ function getSearchSubmitUpdate({
domain, domain,
subdomains, scope,
sort, sort,
locationCode, locationCode,
currentOrder, currentOrder,
@ -152,7 +160,7 @@ function getSearchSubmitUpdate({
defaultLocationCode, defaultLocationCode,
}: { }: {
domain: string; domain: string;
subdomains: boolean; scope: ResearchScope;
sort: DomainSortMode; sort: DomainSortMode;
locationCode: number; locationCode: number;
currentOrder: SortOrder; currentOrder: SortOrder;
@ -162,7 +170,8 @@ function getSearchSubmitUpdate({
return { return {
...buildDomainFiltersClearSearchUpdate(), ...buildDomainFiltersClearSearchUpdate(),
domain, domain,
subdomains: subdomains ? undefined : false, scope: toScopeSearchParam(domain, scope),
subdomains: undefined,
sort: toSortSearchParam(sort), sort: toSortSearchParam(sort),
order: toSortOrderSearchParam(sort, currentOrder), order: toSortOrderSearchParam(sort, currentOrder),
tab: activeTab === "keywords" ? undefined : activeTab, tab: activeTab === "keywords" ? undefined : activeTab,
@ -181,6 +190,9 @@ function useDomainOverviewState({
projectId: string; projectId: string;
}) { }) {
const lastTrackedKey = useRef<string>(""); const lastTrackedKey = useRef<string>("");
// While editing the domain input, the scope tracks the input's default until
// the user picks one; a pick survives further edits unless it turns invalid.
const userPickedScope = useRef(false);
const { const {
history, history,
@ -264,7 +276,7 @@ function useDomainOverviewState({
const overviewQuery = useDomainOverviewQuery({ const overviewQuery = useDomainOverviewQuery({
projectId, projectId,
domain: routeState.domain, domain: routeState.domain,
includeSubdomains: routeState.subdomains, scope: routeState.scope,
locationCode: routeState.sentLocationCode, locationCode: routeState.sentLocationCode,
}); });
const overview = overviewQuery.data ?? null; const overview = overviewQuery.data ?? null;
@ -273,7 +285,7 @@ function useDomainOverviewState({
const controlsForm = useForm({ const controlsForm = useForm({
defaultValues: { defaultValues: {
domain: routeState.domain, domain: routeState.domain,
subdomains: routeState.subdomains, scope: routeState.scope,
sort: routeState.sort, sort: routeState.sort,
locationCode: routeState.locationCode, locationCode: routeState.locationCode,
}, },
@ -287,13 +299,15 @@ function useDomainOverviewState({
onSubmit: ({ value }) => getDomainSearchValidationErrors(value), onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
}, },
onSubmit: ({ formApi, value }) => { onSubmit: ({ formApi, value }) => {
const target = normalizeDomainTarget(value.domain); const parsed = parseResearchTarget(value.domain, value.scope);
if (!target) return; if (!parsed.ok) return;
formApi.setFieldValue("domain", target); const target = parsed.target;
formApi.setFieldValue("domain", target.display);
formApi.setFieldValue("scope", target.scope);
setSearchParams( setSearchParams(
getSearchSubmitUpdate({ getSearchSubmitUpdate({
domain: target, domain: target.display,
subdomains: value.subdomains, scope: target.scope,
sort: value.sort, sort: value.sort,
locationCode: value.locationCode, locationCode: value.locationCode,
currentOrder: routeState.order, currentOrder: routeState.order,
@ -305,9 +319,10 @@ function useDomainOverviewState({
}); });
useEffect(() => { useEffect(() => {
userPickedScope.current = false;
controlsForm.reset({ controlsForm.reset({
domain: routeState.domain, domain: routeState.domain,
subdomains: routeState.subdomains, scope: routeState.scope,
sort: routeState.sort, sort: routeState.sort,
locationCode: routeState.locationCode, locationCode: routeState.locationCode,
}); });
@ -315,10 +330,29 @@ function useDomainOverviewState({
controlsForm, controlsForm,
routeState.domain, routeState.domain,
routeState.locationCode, routeState.locationCode,
routeState.scope,
routeState.sort, routeState.sort,
routeState.subdomains,
]); ]);
const handleDomainChange = useCallback(
(nextDomain: string) => {
// An explicit pick sticks even when it stops fitting the input (e.g.
// Subfolder after the path is deleted) — submit validation explains
// instead of the select silently changing under the user.
if (userPickedScope.current) return;
const path = getResearchInputPath(nextDomain);
const nextScope = defaultScopeForPath(path);
if (nextScope !== controlsForm.getFieldValue("scope")) {
controlsForm.setFieldValue("scope", nextScope);
}
},
[controlsForm],
);
const handleScopeChange = useCallback(() => {
userPickedScope.current = true;
}, []);
useEffect(() => { useEffect(() => {
controlsForm.setErrorMap({ controlsForm.setErrorMap({
onSubmit: overviewQuery.error onSubmit: overviewQuery.error
@ -334,19 +368,19 @@ function useDomainOverviewState({
useEffect(() => { useEffect(() => {
if (!overviewQuery.isSuccess || !overview) return; if (!overviewQuery.isSuccess || !overview) return;
const key = `${routeState.domain}|${routeState.subdomains}|${routeState.locationCode}`; const key = `${routeState.domain}|${routeState.scope}|${routeState.locationCode}`;
if (lastTrackedKey.current === key) return; if (lastTrackedKey.current === key) return;
lastTrackedKey.current = key; lastTrackedKey.current = key;
captureClientEvent("domain_overview:search_complete", { captureClientEvent("domain_overview:search_complete", {
sort_mode: routeState.sort, sort_mode: routeState.sort,
include_subdomains: routeState.subdomains, scope: routeState.scope,
result_count: overview.organicKeywords ?? 0, result_count: overview.organicKeywords ?? 0,
location_code: routeState.locationCode, location_code: routeState.locationCode,
}); });
addSearch({ addSearch({
domain: routeState.domain, domain: routeState.domain,
subdomains: routeState.subdomains, scope: routeState.scope,
sort: routeState.sort, sort: routeState.sort,
tab: routeState.tab, tab: routeState.tab,
locationCode: routeState.locationCode, locationCode: routeState.locationCode,
@ -360,8 +394,8 @@ function useDomainOverviewState({
overviewQuery.isSuccess, overviewQuery.isSuccess,
routeState.domain, routeState.domain,
routeState.locationCode, routeState.locationCode,
routeState.scope,
routeState.sort, routeState.sort,
routeState.subdomains,
routeState.tab, routeState.tab,
]); ]);
@ -401,6 +435,8 @@ function useDomainOverviewState({
setSearchParams, setSearchParams,
applySort, applySort,
applyLocationChange, applyLocationChange,
handleDomainChange,
handleScopeChange,
handleTabChange, handleTabChange,
handleSortColumnClick, handleSortColumnClick,
handleHistorySelect, handleHistorySelect,
@ -430,10 +466,10 @@ export function DomainOverviewPage({
return { return {
type: "domain", type: "domain",
domain: routeState.domain, domain: routeState.domain,
subdomains: routeState.subdomains, scope: routeState.scope,
locationCode: routeState.sentLocationCode, locationCode: routeState.sentLocationCode,
}; };
}, [routeState.domain, routeState.sentLocationCode, routeState.subdomains]); }, [routeState.domain, routeState.scope, routeState.sentLocationCode]);
const navigateToSearchTab = useCallback( const navigateToSearchTab = useCallback(
(input: SearchTabInput | null) => { (input: SearchTabInput | null) => {
@ -450,7 +486,8 @@ export function DomainOverviewPage({
...prev, ...prev,
...buildDomainFiltersClearSearchUpdate(), ...buildDomainFiltersClearSearchUpdate(),
domain: input.domain, domain: input.domain,
subdomains: input.subdomains ? undefined : false, scope: toScopeSearchParam(input.domain, input.scope),
subdomains: undefined,
sort: undefined, sort: undefined,
order: undefined, order: undefined,
tab: undefined, tab: undefined,
@ -482,6 +519,13 @@ export function DomainOverviewPage({
navigateToInput: navigateToSearchTab, navigateToInput: navigateToSearchTab,
}); });
// domain_rank_overview can't be narrowed: its metrics always cover the
// hostname plus subdomains, so anything narrower needs a label.
const overviewMetricsHint =
state.overview && state.overview.scope !== "subdomains"
? "Whole domain incl. subdomains"
: undefined;
const tabControls = routeState.domain ? ( const tabControls = routeState.domain ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
@ -523,6 +567,8 @@ export function DomainOverviewPage({
controlsForm={state.controlsForm} controlsForm={state.controlsForm}
isLoading={state.isLoading} isLoading={state.isLoading}
onSubmit={state.handleSearchSubmit} onSubmit={state.handleSearchSubmit}
onDomainChange={state.handleDomainChange}
onScopeChange={state.handleScopeChange}
onSortChange={(sort) => onSortChange={(sort) =>
state.applySort(sort, getDefaultSortOrder(sort)) state.applySort(sort, getDefaultSortOrder(sort))
} }
@ -548,6 +594,14 @@ export function DomainOverviewPage({
) : ( ) : (
<> <>
{tabControls} {tabControls}
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-ghost font-medium">
{state.overview.displayTarget}
</span>
<span className="badge badge-outline">
{RESEARCH_SCOPE_LABELS[state.overview.scope]}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<StatCard <StatCard
label="Estimated Organic Traffic" label="Estimated Organic Traffic"
@ -555,6 +609,7 @@ export function DomainOverviewPage({
state.overview.organicTraffic, state.overview.organicTraffic,
state.overview.hasData, state.overview.hasData,
)} )}
hint={overviewMetricsHint}
/> />
<StatCard <StatCard
label="Organic Keywords" label="Organic Keywords"
@ -562,14 +617,15 @@ export function DomainOverviewPage({
state.overview.organicKeywords, state.overview.organicKeywords,
state.overview.hasData, state.overview.hasData,
)} )}
hint={overviewMetricsHint}
/> />
</div> </div>
{!state.overview.hasData ? ( {!state.overview.hasData ? (
<div className="alert alert-info"> <div className="alert alert-info">
<span> <span>
Not enough data for this domain yet. Try another domain or Not enough data for this scope yet. Try another domain or a
include subdomains. broader scope.
</span> </span>
</div> </div>
) : null} ) : null}
@ -602,7 +658,9 @@ export function DomainOverviewPage({
<KeywordsTab <KeywordsTab
key="keywords" key="keywords"
projectId={projectId} projectId={projectId}
domain={state.overview.domain} target={state.overview.displayTarget}
hostname={state.overview.domain}
scope={state.overview.scope}
routeState={routeState} routeState={routeState}
canSaveKeywords={state.canSaveKeywords} canSaveKeywords={state.canSaveKeywords}
setSearchParams={state.setSearchParams} setSearchParams={state.setSearchParams}
@ -614,7 +672,9 @@ export function DomainOverviewPage({
<PagesTab <PagesTab
key="pages" key="pages"
projectId={projectId} projectId={projectId}
domain={state.overview.domain} target={state.overview.displayTarget}
hostname={state.overview.domain}
scope={state.overview.scope}
routeState={routeState} routeState={routeState}
setSearchParams={state.setSearchParams} setSearchParams={state.setSearchParams}
onSortClick={state.handleSortColumnClick} onSortClick={state.handleSortColumnClick}

View File

@ -40,6 +40,8 @@ type Props<TValues extends FilterValues> = {
textFields: ReadonlyArray<FilterTextField<TValues>>; textFields: ReadonlyArray<FilterTextField<TValues>>;
rangeFields: ReadonlyArray<FilterRangeField<TValues>>; rangeFields: ReadonlyArray<FilterRangeField<TValues>>;
countConditions: (values: TValues) => number; countConditions: (values: TValues) => number;
/** Conditions left for user filters once scope filters take their share. */
maxConditions?: number;
onApply: (values: TValues) => void; onApply: (values: TValues) => void;
onClear: () => void; onClear: () => void;
/** Extra feature-specific controls (toggles etc.) bound to the draft. */ /** Extra feature-specific controls (toggles etc.) bound to the draft. */
@ -57,6 +59,7 @@ export function DomainFilterPanel<TValues extends FilterValues>({
textFields, textFields,
rangeFields, rangeFields,
countConditions, countConditions,
maxConditions = MAX_DATAFORSEO_FILTER_CONDITIONS,
onApply, onApply,
onClear, onClear,
renderExtra, renderExtra,
@ -79,8 +82,9 @@ export function DomainFilterPanel<TValues extends FilterValues>({
appliedFilters, appliedFilters,
fields, fields,
countConditions, countConditions,
maxConditions,
}), }),
[appliedFilters, countConditions, draftFilters, fields], [appliedFilters, countConditions, draftFilters, fields, maxConditions],
); );
useDomainRenderDebug(debugName, { useDomainRenderDebug(debugName, {
activeFilterCount, activeFilterCount,
@ -204,15 +208,14 @@ export function DomainFilterPanel<TValues extends FilterValues>({
<div className="alert alert-warning py-2 text-xs"> <div className="alert alert-warning py-2 text-xs">
<AlertTriangle className="size-4 shrink-0" /> <AlertTriangle className="size-4 shrink-0" />
<span> <span>
Too many filter conditions ({meta.conditionCount} of{" "} Too many filter conditions ({meta.conditionCount} of {maxConditions}{" "}
{MAX_DATAFORSEO_FILTER_CONDITIONS} max). Remove some terms or ranges max). Remove some terms or ranges before applying.
before applying.
</span> </span>
</div> </div>
) : null} ) : null}
<div className="flex items-center justify-between gap-2 pt-1"> <div className="flex items-center justify-between gap-2 pt-1">
<span className="text-xs text-base-content/50 tabular-nums"> <span className="text-xs text-base-content/50 tabular-nums">
{meta.conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions {meta.conditionCount} / {maxConditions} conditions
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -230,7 +233,7 @@ export function DomainFilterPanel<TValues extends FilterValues>({
disabled={!meta.isDirty || meta.overLimit} disabled={!meta.isDirty || meta.overLimit}
title={ title={
meta.overLimit meta.overLimit
? `DataForSEO accepts at most ${MAX_DATAFORSEO_FILTER_CONDITIONS} filter conditions per request` ? `This scope leaves room for at most ${maxConditions} filter conditions per request`
: undefined : undefined
} }
> >
@ -252,11 +255,13 @@ function getFilterMeta<TValues extends FilterValues>({
appliedFilters, appliedFilters,
fields, fields,
countConditions, countConditions,
maxConditions,
}: { }: {
values: TValues; values: TValues;
appliedFilters: TValues; appliedFilters: TValues;
fields: ReadonlyArray<keyof TValues>; fields: ReadonlyArray<keyof TValues>;
countConditions: (values: TValues) => number; countConditions: (values: TValues) => number;
maxConditions: number;
}) { }) {
const conditionCount = countConditions(values); const conditionCount = countConditions(values);
const dirtyCount = fields.reduce( const dirtyCount = fields.reduce(
@ -268,6 +273,6 @@ function getFilterMeta<TValues extends FilterValues>({
conditionCount, conditionCount,
dirtyCount, dirtyCount,
isDirty: dirtyCount > 0, isDirty: dirtyCount > 0,
overLimit: conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS, overLimit: conditionCount > maxConditions,
}; };
} }

View File

@ -1,6 +1,7 @@
import { Clock, History, X } from "lucide-react"; import { Clock, History, X } from "lucide-react";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import type { DomainHistoryItem } from "@/client/features/domain/types"; import type { DomainHistoryItem } from "@/client/features/domain/types";
import { RESEARCH_SCOPE_LABELS } from "@/shared/researchScope";
type Props = { type Props = {
history: DomainHistoryItem[]; history: DomainHistoryItem[];
@ -58,7 +59,7 @@ export function DomainHistorySection({
{item.domain} {item.domain}
</p> </p>
<p className="text-sm text-base-content/60 truncate"> <p className="text-sm text-base-content/60 truncate">
{item.subdomains ? "Include subdomains" : "Root domain only"} {RESEARCH_SCOPE_LABELS[item.scope]}
</p> </p>
</div> </div>
</button> </button>

View File

@ -6,11 +6,15 @@ import { toSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode } from "@/client/features/domain/types"; import type { DomainSortMode } from "@/client/features/domain/types";
import { LABS_LOCATION_OPTIONS } from "@/client/features/keywords/locations"; import { LABS_LOCATION_OPTIONS } from "@/client/features/keywords/locations";
import { LocationSelect } from "@/client/components/LocationSelect"; import { LocationSelect } from "@/client/components/LocationSelect";
import { ResearchScopeSelect } from "@/client/components/ResearchScopeSelect";
import type { ResearchScope } from "@/shared/researchScope";
type Props = { type Props = {
controlsForm: DomainOverviewControlsForm; controlsForm: DomainOverviewControlsForm;
isLoading: boolean; isLoading: boolean;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
onDomainChange: (domain: string) => void;
onScopeChange: (scope: ResearchScope) => void;
onSortChange: (sort: DomainSortMode) => void; onSortChange: (sort: DomainSortMode) => void;
onLocationChange: (locationCode: number) => void; onLocationChange: (locationCode: number) => void;
}; };
@ -19,6 +23,8 @@ export function DomainSearchCard({
controlsForm, controlsForm,
isLoading, isLoading,
onSubmit, onSubmit,
onDomainChange,
onScopeChange,
onSortChange, onSortChange,
onLocationChange, onLocationChange,
}: Props) { }: Props) {
@ -40,9 +46,12 @@ export function DomainSearchCard({
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
<input <input
className="grow min-w-0" className="grow min-w-0"
placeholder="Enter a domain" placeholder="Enter a domain or URL"
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => {
field.handleChange(event.target.value);
onDomainChange(event.target.value);
}}
aria-invalid={domainError ? true : undefined} aria-invalid={domainError ? true : undefined}
aria-describedby={ aria-describedby={
domainError ? "domain-input-error" : undefined domainError ? "domain-input-error" : undefined
@ -53,6 +62,19 @@ export function DomainSearchCard({
}} }}
</controlsForm.Field> </controlsForm.Field>
<controlsForm.Field name="scope">
{(field) => (
<ResearchScopeSelect
value={field.state.value}
className="w-full lg:w-40"
onChange={(scope) => {
field.handleChange(scope);
onScopeChange(scope);
}}
/>
)}
</controlsForm.Field>
<controlsForm.Field name="locationCode"> <controlsForm.Field name="locationCode">
{(field) => ( {(field) => (
<LocationSelect <LocationSelect
@ -124,22 +146,6 @@ export function DomainSearchCard({
) : null; ) : null;
}} }}
</controlsForm.Subscribe> </controlsForm.Subscribe>
<div className="flex flex-wrap items-center gap-3">
<label className="label cursor-pointer gap-2 py-0">
<controlsForm.Field name="subdomains">
{(field) => (
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={field.state.value}
onChange={(event) => field.handleChange(event.target.checked)}
/>
)}
</controlsForm.Field>
<span className="label-text">Include subdomains</span>
</label>
</div>
</div> </div>
</div> </div>
); );

View File

@ -25,6 +25,7 @@ import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomain
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations"; import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
import { useDomainKeywordFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences"; import { useDomainKeywordFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences";
import { import {
EMPTY_DOMAIN_FILTERS,
type DomainSortMode, type DomainSortMode,
type KeywordRow, type KeywordRow,
type KeywordsFilterValues, type KeywordsFilterValues,
@ -38,6 +39,10 @@ import {
MAX_DATAFORSEO_FILTER_CONDITIONS, MAX_DATAFORSEO_FILTER_CONDITIONS,
type DomainSearchParams, type DomainSearchParams,
} from "@/types/schemas/domain"; } from "@/types/schemas/domain";
import {
RESEARCH_SCOPE_FILTER_SLOTS,
type ResearchScope,
} from "@/shared/researchScope";
type SearchUpdate = Partial<DomainSearchParams>; type SearchUpdate = Partial<DomainSearchParams>;
@ -64,7 +69,11 @@ const KEYWORD_RANGE_FILTERS = [
type Props = { type Props = {
projectId: string; projectId: string;
domain: string; /** Research target as displayed: hostname, plus the path for URL scopes. */
target: string;
/** Hostname only, for resolving relative result URLs. */
hostname: string;
scope: ResearchScope;
routeState: DomainOverviewRouteState; routeState: DomainOverviewRouteState;
canSaveKeywords: boolean; canSaveKeywords: boolean;
setSearchParams: (updates: SearchUpdate) => void; setSearchParams: (updates: SearchUpdate) => void;
@ -75,7 +84,9 @@ type Props = {
export function KeywordsTab({ export function KeywordsTab({
projectId, projectId,
domain, target,
hostname,
scope,
routeState, routeState,
canSaveKeywords, canSaveKeywords,
setSearchParams, setSearchParams,
@ -88,29 +99,41 @@ export function KeywordsTab({
new Set(), new Set(),
); );
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
// Scope filters consume part of DataForSEO's fixed filter budget.
const maxConditions =
MAX_DATAFORSEO_FILTER_CONDITIONS -
RESEARCH_SCOPE_FILTER_SLOTS.keywords[scope];
const filterPreferences = useDomainKeywordFilterPreferences( const filterPreferences = useDomainKeywordFilterPreferences(
`${projectId}:${domain}`, `${projectId}:${target}`,
); );
const { const {
filters: preferredFilters, filters: preferredFilters,
save: savePreferredFilters, save: savePreferredFilters,
clear: clearPreferredFilters, clear: clearPreferredFilters,
} = filterPreferences; } = filterPreferences;
const appliedFilters = routeState.hasAppliedKeywordFilters const restoredFilters = routeState.hasAppliedKeywordFilters
? routeState.appliedFilters ? routeState.appliedFilters
: preferredFilters; : preferredFilters;
// Filters restored from the URL or saved preferences can exceed this
// scope's tighter budget; sending them would make the server reject the
// whole query, so hold them back and let the panel explain.
const filtersOverBudget =
countKeywordFilterConditions(restoredFilters) > maxConditions;
const appliedFilters = filtersOverBudget
? EMPTY_DOMAIN_FILTERS
: restoredFilters;
const query = useDomainKeywordsQuery({ const query = useDomainKeywordsQuery({
projectId, projectId,
domain, domain: target,
includeSubdomains: routeState.subdomains, scope,
locationCode: routeState.sentLocationCode, locationCode: routeState.sentLocationCode,
page: routeState.page, page: routeState.page,
pageSize: routeState.pageSize, pageSize: routeState.pageSize,
sortMode: routeState.sort, sortMode: routeState.sort,
sortOrder: routeState.order, sortOrder: routeState.order,
appliedFilters, appliedFilters,
enabled: Boolean(domain), enabled: Boolean(target),
}); });
const rows = query.data?.keywords ?? EMPTY_KEYWORDS; const rows = query.data?.keywords ?? EMPTY_KEYWORDS;
@ -168,16 +191,13 @@ export function KeywordsTab({
const applyFilters = useCallback( const applyFilters = useCallback(
(values: KeywordsFilterValues) => { (values: KeywordsFilterValues) => {
if ( if (countKeywordFilterConditions(values) > maxConditions) return;
countKeywordFilterConditions(values) > MAX_DATAFORSEO_FILTER_CONDITIONS
)
return;
const update = buildKeywordsSearchUpdate(values); const update = buildKeywordsSearchUpdate(values);
debugDomain("KeywordsTab:apply-filters", { values, update }); debugDomain("KeywordsTab:apply-filters", { values, update });
savePreferredFilters(values); savePreferredFilters(values);
setSearchParams(update); setSearchParams(update);
}, },
[savePreferredFilters, setSearchParams], [maxConditions, savePreferredFilters, setSearchParams],
); );
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
@ -190,12 +210,13 @@ export function KeywordsTab({
const activeFilterCount = useMemo( const activeFilterCount = useMemo(
() => () =>
KEYWORD_FILTER_FIELDS.filter((k) => appliedFilters[k].trim() !== "") KEYWORD_FILTER_FIELDS.filter((k) => restoredFilters[k].trim() !== "")
.length, .length,
[appliedFilters], [restoredFilters],
); );
const exportTable = useMemo(() => keywordsToTable(rows), [rows]); const exportTable = useMemo(() => keywordsToTable(rows), [rows]);
const fileNamePrefix = target.replaceAll("/", "-");
const selectedExportTable = useMemo( const selectedExportTable = useMemo(
() => keywordsToTable(rows.filter((r) => selectedKeywords.has(r.keyword))), () => keywordsToTable(rows.filter((r) => selectedKeywords.has(r.keyword))),
[rows, selectedKeywords], [rows, selectedKeywords],
@ -214,7 +235,7 @@ export function KeywordsTab({
}; };
const handleDownload = (extension: "csv" | "xls") => { const handleDownload = (extension: "csv" | "xls") => {
downloadCsv( downloadCsv(
`${domain}-keywords.${extension}`, `${fileNamePrefix}-keywords.${extension}`,
buildCsv(exportTable.headers, exportTable.rows), buildCsv(exportTable.headers, exportTable.rows),
); );
if (extension === "csv") { if (extension === "csv") {
@ -233,7 +254,7 @@ export function KeywordsTab({
}; };
const handleDownloadSelectionCsv = () => { const handleDownloadSelectionCsv = () => {
downloadCsv( downloadCsv(
`${domain}-selected-keywords.csv`, `${fileNamePrefix}-selected-keywords.csv`,
buildCsv(selectedExportTable.headers, selectedExportTable.rows), buildCsv(selectedExportTable.headers, selectedExportTable.rows),
); );
captureClientEvent("data:export", { captureClientEvent("data:export", {
@ -275,6 +296,15 @@ export function KeywordsTab({
} }
/> />
{filtersOverBudget ? (
<div className="alert alert-warning mb-3">
<span>
Saved filters exceed this scope&apos;s {maxConditions}-condition
limit and were not applied. Open Filters to trim them.
</span>
</div>
) : null}
<DomainTableTabSurface <DomainTableTabSurface
showFilters={showFilters} showFilters={showFilters}
onToggleFilters={() => setShowFilters((prev) => !prev)} onToggleFilters={() => setShowFilters((prev) => !prev)}
@ -311,11 +341,12 @@ export function KeywordsTab({
<DomainFilterPanel <DomainFilterPanel
debugName="KeywordsFilterPanel" debugName="KeywordsFilterPanel"
activeFilterCount={activeFilterCount} activeFilterCount={activeFilterCount}
appliedFilters={appliedFilters} appliedFilters={restoredFilters}
fields={KEYWORD_FILTER_FIELDS} fields={KEYWORD_FILTER_FIELDS}
textFields={KEYWORD_TEXT_FILTERS} textFields={KEYWORD_TEXT_FILTERS}
rangeFields={KEYWORD_RANGE_FILTERS} rangeFields={KEYWORD_RANGE_FILTERS}
countConditions={countKeywordFilterConditions} countConditions={countKeywordFilterConditions}
maxConditions={maxConditions}
onApply={applyFilters} onApply={applyFilters}
onClear={resetFilters} onClear={resetFilters}
/> />
@ -334,7 +365,7 @@ export function KeywordsTab({
} }
> >
<DomainKeywordsTable <DomainKeywordsTable
domain={domain} domain={hostname}
rows={rows} rows={rows}
selectedKeywords={selectedKeywords} selectedKeywords={selectedKeywords}
visibleKeywords={visibleKeywords} visibleKeywords={visibleKeywords}

View File

@ -18,6 +18,7 @@ import {
import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery"; import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery";
import { useDomainPageFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences"; import { useDomainPageFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences";
import { import {
EMPTY_DOMAIN_FILTERS,
type DomainSortMode, type DomainSortMode,
type PageRow, type PageRow,
type PagesFilterValues, type PagesFilterValues,
@ -31,6 +32,10 @@ import {
MAX_DATAFORSEO_FILTER_CONDITIONS, MAX_DATAFORSEO_FILTER_CONDITIONS,
type DomainSearchParams, type DomainSearchParams,
} from "@/types/schemas/domain"; } from "@/types/schemas/domain";
import {
RESEARCH_SCOPE_FILTER_SLOTS,
type ResearchScope,
} from "@/shared/researchScope";
type SearchUpdate = Partial<DomainSearchParams>; type SearchUpdate = Partial<DomainSearchParams>;
@ -54,7 +59,11 @@ const PAGE_RANGE_FILTERS = [
type Props = { type Props = {
projectId: string; projectId: string;
domain: string; /** Research target as displayed: hostname, plus the path for URL scopes. */
target: string;
/** Hostname only, for resolving relative result URLs. */
hostname: string;
scope: ResearchScope;
routeState: DomainOverviewRouteState; routeState: DomainOverviewRouteState;
setSearchParams: (updates: SearchUpdate) => void; setSearchParams: (updates: SearchUpdate) => void;
onSortClick: (sort: DomainSortMode) => void; onSortClick: (sort: DomainSortMode) => void;
@ -64,7 +73,9 @@ type Props = {
export function PagesTab({ export function PagesTab({
projectId, projectId,
domain, target,
hostname,
scope,
routeState, routeState,
setSearchParams, setSearchParams,
onSortClick, onSortClick,
@ -72,15 +83,18 @@ export function PagesTab({
onPageSizeChange, onPageSizeChange,
}: Props) { }: Props) {
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
// Scope filters consume part of DataForSEO's fixed filter budget.
const maxConditions =
MAX_DATAFORSEO_FILTER_CONDITIONS - RESEARCH_SCOPE_FILTER_SLOTS.pages[scope];
const filterPreferences = useDomainPageFilterPreferences( const filterPreferences = useDomainPageFilterPreferences(
`${projectId}:${domain}`, `${projectId}:${target}`,
); );
const { const {
filters: preferredFilters, filters: preferredFilters,
save: savePreferredFilters, save: savePreferredFilters,
clear: clearPreferredFilters, clear: clearPreferredFilters,
} = filterPreferences; } = filterPreferences;
const appliedPagesFilters = useMemo( const restoredFilters = useMemo(
() => () =>
routeState.hasAppliedPageFilters routeState.hasAppliedPageFilters
? routeState.appliedPageFilters ? routeState.appliedPageFilters
@ -91,18 +105,26 @@ export function PagesTab({
routeState.hasAppliedPageFilters, routeState.hasAppliedPageFilters,
], ],
); );
// Filters restored from the URL or saved preferences can exceed this
// scope's tighter budget; sending them would make the server reject the
// whole query, so hold them back and let the panel explain.
const filtersOverBudget =
countPageFilterConditions(restoredFilters) > maxConditions;
const appliedPagesFilters = filtersOverBudget
? EMPTY_DOMAIN_FILTERS
: restoredFilters;
const query = useDomainPagesQuery({ const query = useDomainPagesQuery({
projectId, projectId,
domain, domain: target,
includeSubdomains: routeState.subdomains, scope,
locationCode: routeState.sentLocationCode, locationCode: routeState.sentLocationCode,
page: routeState.page, page: routeState.page,
pageSize: routeState.pageSize, pageSize: routeState.pageSize,
sortMode: routeState.sort, sortMode: routeState.sort,
sortOrder: routeState.order, sortOrder: routeState.order,
appliedFilters: appliedPagesFilters, appliedFilters: appliedPagesFilters,
enabled: Boolean(domain), enabled: Boolean(target),
}); });
const rows = query.data?.pages ?? EMPTY_PAGES_ROWS; const rows = query.data?.pages ?? EMPTY_PAGES_ROWS;
@ -124,14 +146,13 @@ export function PagesTab({
const applyFilters = useCallback( const applyFilters = useCallback(
(values: PagesFilterValues) => { (values: PagesFilterValues) => {
if (countPageFilterConditions(values) > MAX_DATAFORSEO_FILTER_CONDITIONS) if (countPageFilterConditions(values) > maxConditions) return;
return;
const update = buildPagesSearchUpdate(values); const update = buildPagesSearchUpdate(values);
debugDomain("PagesTab:apply-filters", { values, update }); debugDomain("PagesTab:apply-filters", { values, update });
savePreferredFilters(values); savePreferredFilters(values);
setSearchParams(update); setSearchParams(update);
}, },
[savePreferredFilters, setSearchParams], [maxConditions, savePreferredFilters, setSearchParams],
); );
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
@ -143,12 +164,12 @@ export function PagesTab({
const activeFilterCount = useMemo( const activeFilterCount = useMemo(
() => () =>
PAGE_FILTER_FIELDS.filter((k) => appliedPagesFilters[k].trim() !== "") PAGE_FILTER_FIELDS.filter((k) => restoredFilters[k].trim() !== "").length,
.length, [restoredFilters],
[appliedPagesFilters],
); );
const exportTable = useMemo(() => pagesToTable(rows), [rows]); const exportTable = useMemo(() => pagesToTable(rows), [rows]);
const fileNamePrefix = target.replaceAll("/", "-");
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText(JSON.stringify(rows, null, 2)); await navigator.clipboard.writeText(JSON.stringify(rows, null, 2));
@ -163,7 +184,7 @@ export function PagesTab({
}; };
const handleDownload = (extension: "csv" | "xls") => { const handleDownload = (extension: "csv" | "xls") => {
downloadCsv( downloadCsv(
`${domain}-pages.${extension}`, `${fileNamePrefix}-pages.${extension}`,
buildCsv(exportTable.headers, exportTable.rows), buildCsv(exportTable.headers, exportTable.rows),
); );
if (extension === "csv") { if (extension === "csv") {
@ -176,6 +197,15 @@ export function PagesTab({
return ( return (
<> <>
{filtersOverBudget ? (
<div className="alert alert-warning mb-3">
<span>
Saved filters exceed this scope&apos;s {maxConditions}-condition
limit and were not applied. Open Filters to trim them.
</span>
</div>
) : null}
<DomainTableTabSurface <DomainTableTabSurface
showFilters={showFilters} showFilters={showFilters}
onToggleFilters={() => setShowFilters((prev) => !prev)} onToggleFilters={() => setShowFilters((prev) => !prev)}
@ -212,11 +242,12 @@ export function PagesTab({
<DomainFilterPanel <DomainFilterPanel
debugName="PagesFilterPanel" debugName="PagesFilterPanel"
activeFilterCount={activeFilterCount} activeFilterCount={activeFilterCount}
appliedFilters={appliedPagesFilters} appliedFilters={restoredFilters}
fields={PAGE_FILTER_FIELDS} fields={PAGE_FILTER_FIELDS}
textFields={PAGE_TEXT_FILTERS} textFields={PAGE_TEXT_FILTERS}
rangeFields={PAGE_RANGE_FILTERS} rangeFields={PAGE_RANGE_FILTERS}
countConditions={countPageFilterConditions} countConditions={countPageFilterConditions}
maxConditions={maxConditions}
onApply={applyFilters} onApply={applyFilters}
onClear={resetFilters} onClear={resetFilters}
/> />
@ -235,7 +266,7 @@ export function PagesTab({
} }
> >
<DomainPagesTable <DomainPagesTable
domain={domain} domain={hostname}
rows={rows} rows={rows}
sortMode={routeState.sort} sortMode={routeState.sort}
currentSortOrder={routeState.order} currentSortOrder={routeState.order}

View File

@ -1,4 +1,12 @@
export function StatCard({ label, value }: { label: string; value: string }) { export function StatCard({
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
}) {
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body p-4"> <div className="card-body p-4">
@ -6,6 +14,7 @@ export function StatCard({ label, value }: { label: string; value: string }) {
{label} {label}
</p> </p>
<p className="text-2xl font-semibold">{value}</p> <p className="text-2xl font-semibold">{value}</p>
{hint ? <p className="text-xs text-base-content/50">{hint}</p> : null}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,41 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getDomainRouteState } from "./domainRouteState"; import { getDomainRouteState } from "./domainRouteState";
import { toScopeSearchParam } from "@/shared/researchScope";
describe("research scope resolution", () => {
it("derives the scope from the domain input when the URL omits it", () => {
expect(getDomainRouteState({ domain: "example.com" }).scope).toBe(
"subdomains",
);
expect(getDomainRouteState({ domain: "example.com/blog" }).scope).toBe(
"subfolder",
);
});
it("migrates the legacy subdomains param", () => {
expect(
getDomainRouteState({ domain: "example.com", subdomains: true }).scope,
).toBe("subdomains");
expect(
getDomainRouteState({ domain: "example.com", subdomains: false }).scope,
).toBe("domain");
});
it("ignores a scope the domain input cannot support", () => {
expect(
getDomainRouteState({ domain: "example.com", scope: "subfolder" }).scope,
).toBe("subdomains");
});
it("omits the scope param when it matches the input's default", () => {
expect(toScopeSearchParam("example.com", "subdomains")).toBeUndefined();
expect(toScopeSearchParam("example.com", "domain")).toBe("domain");
expect(toScopeSearchParam("example.com/blog", "subfolder")).toBeUndefined();
expect(toScopeSearchParam("example.com/blog", "exact_url")).toBe(
"exact_url",
);
});
});
describe("getDomainRouteState", () => { describe("getDomainRouteState", () => {
it("uses a Labs-backed project market when the URL omits loc", () => { it("uses a Labs-backed project market when the URL omits loc", () => {

View File

@ -21,11 +21,21 @@ import {
PAGE_FILTER_FIELDS, PAGE_FILTER_FIELDS,
PAGE_SEARCH_PARAM_BY_FIELD, PAGE_SEARCH_PARAM_BY_FIELD,
} from "@/client/features/domain/domainFilterUtils"; } from "@/client/features/domain/domainFilterUtils";
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils"; import {
defaultScopeForPath,
isScopeAllowedForInput,
type ResearchScope,
} from "@/shared/researchScope";
import {
getResearchInputPath,
resolveSortOrder,
toSortMode,
toSortOrder,
} from "./utils";
export type DomainOverviewRouteState = { export type DomainOverviewRouteState = {
domain: string; domain: string;
subdomains: boolean; scope: ResearchScope;
sort: DomainSortMode; sort: DomainSortMode;
order: SortOrder; order: SortOrder;
tab: DomainActiveTab; tab: DomainActiveTab;
@ -40,6 +50,18 @@ export type DomainOverviewRouteState = {
hasAppliedPageFilters: boolean; hasAppliedPageFilters: boolean;
}; };
function resolveScope(search: DomainSearchParams): ResearchScope {
const path = getResearchInputPath(search.domain ?? "");
if (search.scope && isScopeAllowedForInput(search.scope, path)) {
return search.scope;
}
// Legacy param: pre-scope URLs encoded "Include subdomains" here.
if (search.subdomains != null) {
return search.subdomains ? "subdomains" : "domain";
}
return defaultScopeForPath(path);
}
function numberToFilterString(value: number | undefined): string { function numberToFilterString(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return ""; if (value == null || !Number.isFinite(value)) return "";
return String(value); return String(value);
@ -62,7 +84,7 @@ export function getDomainRouteState(
return { return {
domain: search.domain ?? "", domain: search.domain ?? "",
subdomains: search.subdomains ?? true, scope: resolveScope(search),
sort: normalizedSort, sort: normalizedSort,
order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)), order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)),
tab: search.tab ?? "keywords", tab: search.tab ?? "keywords",

View File

@ -1,4 +1,4 @@
import { normalizeDomainTarget } from "@/client/features/domain/utils"; import { parseResearchTarget } from "@/shared/researchScope";
import { createFormValidationErrors } from "@/client/lib/forms"; import { createFormValidationErrors } from "@/client/lib/forms";
import type { DomainControlsValues } from "@/client/features/domain/types"; import type { DomainControlsValues } from "@/client/features/domain/types";
@ -11,10 +11,11 @@ export function getDomainSearchValidationErrors(value: DomainControlsValues) {
}); });
} }
if (!normalizeDomainTarget(value.domain)) { const parsed = parseResearchTarget(value.domain, value.scope);
if (!parsed.ok) {
return createFormValidationErrors({ return createFormValidationErrors({
fields: { fields: {
domain: "Please enter a valid URL or domain (e.g. example.com)", domain: parsed.message,
}, },
}); });
} }

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getDomainKeywordsPage } from "@/serverFunctions/domain"; import { getDomainKeywordsPage } from "@/serverFunctions/domain";
import { debugDomain } from "@/client/features/domain/domainDebug"; import { debugDomain } from "@/client/features/domain/domainDebug";
import type { ResearchScope } from "@/shared/researchScope";
import type { import type {
DomainFilterValues, DomainFilterValues,
DomainSortMode, DomainSortMode,
@ -11,7 +12,7 @@ import type {
type DomainKeywordsQueryInput = { type DomainKeywordsQueryInput = {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; scope: ResearchScope;
locationCode: number | undefined; locationCode: number | undefined;
page: number; page: number;
pageSize: number; pageSize: number;
@ -57,7 +58,7 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
"domain-keywords", "domain-keywords",
input.projectId, input.projectId,
input.domain, input.domain,
input.includeSubdomains, input.scope,
input.locationCode, input.locationCode,
input.page, input.page,
input.pageSize, input.pageSize,
@ -68,7 +69,7 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
[ [
filtersPayload, filtersPayload,
input.domain, input.domain,
input.includeSubdomains, input.scope,
input.locationCode, input.locationCode,
input.page, input.page,
input.pageSize, input.pageSize,
@ -93,7 +94,7 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
data: { data: {
projectId: input.projectId, projectId: input.projectId,
domain: input.domain, domain: input.domain,
includeSubdomains: input.includeSubdomains, scope: input.scope,
locationCode: input.locationCode, locationCode: input.locationCode,
page: input.page, page: input.page,
pageSize: input.pageSize, pageSize: input.pageSize,

View File

@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getDomainOverview } from "@/serverFunctions/domain"; import { getDomainOverview } from "@/serverFunctions/domain";
import type { ResearchScope } from "@/shared/researchScope";
type Input = { type Input = {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; scope: ResearchScope;
locationCode: number | undefined; locationCode: number | undefined;
}; };
@ -17,7 +18,7 @@ export function useDomainOverviewQuery(input: Input) {
"domain-overview", "domain-overview",
input.projectId, input.projectId,
trimmedDomain, trimmedDomain,
input.includeSubdomains, input.scope,
input.locationCode, input.locationCode,
], ],
queryFn: () => queryFn: () =>
@ -25,7 +26,7 @@ export function useDomainOverviewQuery(input: Input) {
data: { data: {
projectId: input.projectId, projectId: input.projectId,
domain: trimmedDomain, domain: trimmedDomain,
includeSubdomains: input.includeSubdomains, scope: input.scope,
locationCode: input.locationCode, locationCode: input.locationCode,
}, },
}), }),

View File

@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { getDomainPagesPage } from "@/serverFunctions/domain"; import { getDomainPagesPage } from "@/serverFunctions/domain";
import { debugDomain } from "@/client/features/domain/domainDebug"; import { debugDomain } from "@/client/features/domain/domainDebug";
import { toPageSortMode } from "@/client/features/domain/utils"; import { toPageSortMode } from "@/client/features/domain/utils";
import type { ResearchScope } from "@/shared/researchScope";
import type { import type {
DomainSortMode, DomainSortMode,
PagesFilterValues, PagesFilterValues,
@ -12,7 +13,7 @@ import type {
type DomainPagesQueryInput = { type DomainPagesQueryInput = {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; scope: ResearchScope;
locationCode: number | undefined; locationCode: number | undefined;
page: number; page: number;
pageSize: number; pageSize: number;
@ -29,7 +30,7 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
"domain-pages", "domain-pages",
input.projectId, input.projectId,
input.domain, input.domain,
input.includeSubdomains, input.scope,
input.locationCode, input.locationCode,
input.page, input.page,
input.pageSize, input.pageSize,
@ -40,7 +41,7 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
[ [
input.appliedFilters, input.appliedFilters,
input.domain, input.domain,
input.includeSubdomains, input.scope,
input.locationCode, input.locationCode,
input.page, input.page,
input.pageSize, input.pageSize,
@ -65,7 +66,7 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
data: { data: {
projectId: input.projectId, projectId: input.projectId,
domain: input.domain, domain: input.domain,
includeSubdomains: input.includeSubdomains, scope: input.scope,
locationCode: input.locationCode, locationCode: input.locationCode,
page: input.page, page: input.page,
pageSize: input.pageSize, pageSize: input.pageSize,

View File

@ -1,3 +1,5 @@
import type { ResearchScope } from "@/shared/researchScope";
export type KeywordRow = { export type KeywordRow = {
keyword: string; keyword: string;
position: number | null; position: number | null;
@ -57,7 +59,7 @@ export type PageFilterKey = keyof PagesFilterValues;
export type DomainControlsValues = { export type DomainControlsValues = {
domain: string; domain: string;
subdomains: boolean; scope: ResearchScope;
sort: "rank" | "traffic" | "volume" | "score" | "cpc"; sort: "rank" | "traffic" | "volume" | "score" | "cpc";
locationCode: number; locationCode: number;
}; };
@ -69,7 +71,7 @@ export type DomainActiveTab = "keywords" | "pages";
export type DomainHistoryItem = { export type DomainHistoryItem = {
timestamp: number; timestamp: number;
domain: string; domain: string;
subdomains: boolean; scope: ResearchScope;
sort: DomainSortMode; sort: DomainSortMode;
tab: DomainActiveTab; tab: DomainActiveTab;
search?: string; search?: string;

View File

@ -4,7 +4,7 @@ import type {
PageRow, PageRow,
SortOrder, SortOrder,
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
import { isValidDomainHost } from "@/types/schemas/domain"; import { parseResearchTarget } from "@/shared/researchScope";
export function toSortMode(value: string | null): DomainSortMode | undefined { export function toSortMode(value: string | null): DomainSortMode | undefined {
if ( if (
@ -55,26 +55,10 @@ export function toPageSortMode(
return "traffic"; return "traffic";
} }
export function normalizeDomainTarget(input: string): string | null { /** Normalized path of a domain input; `""` when it is a root or unparseable. */
const value = input.trim(); export function getResearchInputPath(input: string): string {
if (!value) return null; const parsed = parseResearchTarget(input);
return parsed.ok ? parsed.target.path : "";
const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value)
? value
: `https://${value}`;
try {
const parsed = new URL(withProtocol);
const hostname = parsed.hostname.toLowerCase();
if (!hostname || !hostname.includes(".")) return null;
if (!/^[a-z\d.-]+$/.test(hostname)) return null;
if (!isValidDomainHost(hostname)) return null;
const path = parsed.pathname === "/" ? "" : parsed.pathname;
return `${hostname}${path}`;
} catch {
return null;
}
} }
export function formatNumber(value: number | null | undefined) { export function formatNumber(value: number | null | undefined) {

View File

@ -193,7 +193,7 @@ function getSearchTabQueryConfig(
"domain-overview", "domain-overview",
projectId, projectId,
trimmedDomain, trimmedDomain,
input.subdomains, input.scope,
input.locationCode, input.locationCode,
], ],
queryFn: () => queryFn: () =>
@ -201,7 +201,7 @@ function getSearchTabQueryConfig(
data: { data: {
projectId, projectId,
domain: trimmedDomain, domain: trimmedDomain,
includeSubdomains: input.subdomains, scope: input.scope,
locationCode: input.locationCode, locationCode: input.locationCode,
}, },
}), }),

View File

@ -2,18 +2,18 @@ import type {
KeywordMode, KeywordMode,
ResultLimit, ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import type { BacklinksTargetScope } from "@/types/schemas/backlinks"; import type { ResearchScope } from "@/shared/researchScope";
export type BacklinksSearchTabInput = { export type BacklinksSearchTabInput = {
type: "backlinks"; type: "backlinks";
target: string; target: string;
scope: BacklinksTargetScope; scope: ResearchScope;
}; };
export type DomainSearchTabInput = { export type DomainSearchTabInput = {
type: "domain"; type: "domain";
domain: string; domain: string;
subdomains: boolean; scope: ResearchScope;
locationCode?: number; locationCode?: number;
}; };

View File

@ -21,7 +21,7 @@ function searchTab(index: number): SearchTab {
input: { input: {
type: "backlinks", type: "backlinks",
target: `example-${index}.com`, target: `example-${index}.com`,
scope: "domain", scope: "subdomains",
}, },
}; };
} }
@ -60,7 +60,7 @@ describe("parseStoredState", () => {
persistedTab({ persistedTab({
type: "domain", type: "domain",
domain: "example.com", domain: "example.com",
subdomains: true, scope: "subfolder",
}), }),
], ],
}); });
@ -70,11 +70,59 @@ describe("parseStoredState", () => {
expect(state.tabs[0].input).toEqual({ expect(state.tabs[0].input).toEqual({
type: "domain", type: "domain",
domain: "example.com", domain: "example.com",
subdomains: true, scope: "subfolder",
locationCode: undefined, locationCode: undefined,
}); });
}); });
it("migrates domain tabs stored before research scopes", () => {
const state = parseStoredState({
activeTabId: null,
tabs: [
{
...persistedTab({
type: "domain",
domain: "a.com",
subdomains: true,
}),
id: "tab-1",
},
{
...persistedTab({
type: "domain",
domain: "b.com",
subdomains: false,
}),
id: "tab-2",
},
{
...persistedTab({
type: "backlinks",
target: "d.com/page",
scope: "page",
}),
id: "tab-3",
},
],
});
expect(state.tabs.map((tab) => tab.input)).toEqual([
{
type: "domain",
domain: "a.com",
scope: "subdomains",
locationCode: undefined,
},
{
type: "domain",
domain: "b.com",
scope: "domain",
locationCode: undefined,
},
{ type: "backlinks", target: "d.com/page", scope: "exact_url" },
]);
});
it("keeps keyword tabs persisted without a locationCode (default location)", () => { it("keeps keyword tabs persisted without a locationCode (default location)", () => {
const state = parseStoredState({ const state = parseStoredState({
activeTabId: "tab-1", activeTabId: "tab-1",
@ -108,7 +156,7 @@ describe("parseStoredState", () => {
persistedTab({ persistedTab({
type: "domain", type: "domain",
domain: "example.com", domain: "example.com",
subdomains: false, scope: "domain",
locationCode: 2840, locationCode: 2840,
}), }),
], ],
@ -125,7 +173,7 @@ describe("parseStoredState", () => {
...persistedTab({ ...persistedTab({
type: "backlinks", type: "backlinks",
target: `example-${index}.com`, target: `example-${index}.com`,
scope: "domain", scope: "subdomains",
}), }),
id: `tab-${index}`, id: `tab-${index}`,
})), })),
@ -140,7 +188,7 @@ describe("parseStoredState", () => {
const state = parseStoredState({ const state = parseStoredState({
activeTabId: null, activeTabId: null,
tabs: [ tabs: [
persistedTab({ type: "domain", subdomains: true }), persistedTab({ type: "domain", scope: "domain" }),
persistedTab({ persistedTab({
type: "keyword", type: "keyword",
keyword: "seo tools", keyword: "seo tools",
@ -150,7 +198,7 @@ describe("parseStoredState", () => {
persistedTab({ persistedTab({
type: "domain", type: "domain",
domain: "example.com", domain: "example.com",
subdomains: true, scope: "domain",
locationCode: "us", locationCode: "us",
}), }),
persistedTab({ type: "unknown" }), persistedTab({ type: "unknown" }),

View File

@ -1,4 +1,8 @@
import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useCallback, useMemo, useSyncExternalStore } from "react";
import {
researchScopeSchema,
type ResearchScope,
} from "@/shared/researchScope";
import type { SearchTab, SearchTabInput } from "./types"; import type { SearchTab, SearchTabInput } from "./types";
type TabsState = { type TabsState = {
@ -24,21 +28,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
function toResearchScope(value: unknown): ResearchScope | null {
const parsed = researchScopeSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
function parseTabInput(value: unknown): SearchTabInput | null { function parseTabInput(value: unknown): SearchTabInput | null {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
if (value.type === "backlinks") { if (value.type === "backlinks") {
if (typeof value.target !== "string" || value.target === "") return null; if (typeof value.target !== "string" || value.target === "") return null;
if (value.scope !== "domain" && value.scope !== "page") return null; // Legacy backlinks tabs stored "page"; "domain" survives as a valid scope.
const scope =
value.scope === "page" ? "exact_url" : toResearchScope(value.scope);
if (!scope) return null;
return { return {
type: "backlinks", type: "backlinks",
target: value.target, target: value.target,
scope: value.scope, scope,
}; };
} }
if (value.type === "domain") { if (value.type === "domain") {
if (typeof value.domain !== "string" || value.domain === "") return null; if (typeof value.domain !== "string" || value.domain === "") return null;
if (typeof value.subdomains !== "boolean") return null;
// locationCode is optional in DomainSearchTabInput: tabs opened at the // locationCode is optional in DomainSearchTabInput: tabs opened at the
// default location persist no loc param, so accept a missing key. // default location persist no loc param, so accept a missing key.
if ( if (
@ -47,10 +58,19 @@ function parseTabInput(value: unknown): SearchTabInput | null {
) { ) {
return null; return null;
} }
// Legacy tabs stored an "include subdomains" boolean instead of a scope.
const scope =
toResearchScope(value.scope) ??
(typeof value.subdomains === "boolean"
? value.subdomains
? "subdomains"
: "domain"
: null);
if (!scope) return null;
return { return {
type: "domain", type: "domain",
domain: value.domain, domain: value.domain,
subdomains: value.subdomains, scope,
locationCode: locationCode:
typeof value.locationCode === "number" ? value.locationCode : undefined, typeof value.locationCode === "number" ? value.locationCode : undefined,
}; };

View File

@ -1,21 +1,62 @@
import { z } from "zod"; import { z } from "zod";
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore"; import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
import {
researchScopeSchema,
type ResearchScope,
} from "@/shared/researchScope";
export interface BacklinksSearchHistoryItem { export interface BacklinksSearchHistoryItem {
target: string; target: string;
scope: "domain" | "page"; scope: ResearchScope;
timestamp: number; timestamp: number;
/** Marks items written with research-scope values (see the codec below). */
scopeVersion: typeof SCOPE_VERSION;
} }
type AddBacklinksSearchInput = Omit<BacklinksSearchHistoryItem, "timestamp">; type AddBacklinksSearchInput = Omit<
BacklinksSearchHistoryItem,
"timestamp" | "scopeVersion"
>;
const MAX_HISTORY = 20; const MAX_HISTORY = 20;
const SCOPE_VERSION = 2;
const backlinksSearchHistoryItemSchema = z.object({ /**
* Before research scopes, "domain" meant the hostname plus its subdomains and
* "page" meant one URL. Both names survive with different meanings, so only
* items lacking `scopeVersion` get translated a new "domain" pick must not
* be re-read as "subdomains".
*/
const LEGACY_SCOPES: Record<string, ResearchScope> = {
domain: "subdomains",
page: "exact_url",
};
const backlinksSearchHistoryItemSchema = z
.object({
target: z.string(), target: z.string(),
scope: z.enum(["domain", "page"]), scope: z.string(),
scopeVersion: z.literal(SCOPE_VERSION).optional(),
timestamp: z.number(), timestamp: z.number(),
})
.transform((item, ctx) => {
const scope = researchScopeSchema.safeParse(
item.scopeVersion === SCOPE_VERSION
? item.scope
: LEGACY_SCOPES[item.scope],
);
if (!scope.success) {
ctx.addIssue({ code: "custom", message: "Unknown backlinks scope" });
return z.NEVER;
}
return {
target: item.target,
scope: scope.data,
timestamp: item.timestamp,
scopeVersion: SCOPE_VERSION,
} satisfies BacklinksSearchHistoryItem;
}); });
const backlinksSearchHistorySchema = z.array(backlinksSearchHistoryItemSchema); const backlinksSearchHistorySchema = z.array(backlinksSearchHistoryItemSchema);
@ -43,6 +84,7 @@ export function useBacklinksSearchHistory(projectId: string) {
createItem: (item) => ({ createItem: (item) => ({
...item, ...item,
timestamp: Date.now(), timestamp: Date.now(),
scopeVersion: SCOPE_VERSION,
}), }),
getItemKey: (item) => item.timestamp, getItemKey: (item) => item.timestamp,
}); });

View File

@ -1,10 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory"; import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory";
import { researchScopeSchema } from "@/shared/researchScope";
const brandLookupSearchBodySchema = z.object({ const brandLookupSearchBodySchema = z.object({
query: z.string(), query: z.string(),
// Optional/defaulted so pre-existing history entries (query only) still parse. // Optional/defaulted so pre-existing history entries (query only) still parse.
competitors: z.array(z.string()).optional().default([]), competitors: z.array(z.string()).optional().default([]),
// Absent on entries saved before scopes existed, and on lookups that used
// the query's default scope — both re-derive the default on restore.
scope: researchScopeSchema.optional(),
}); });
type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>; type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>;
@ -21,6 +25,7 @@ export function useBrandLookupSearchHistory(projectId: string) {
// the saved (already paid for) Share-of-Voice comparison of the same brand. // the saved (already paid for) Share-of-Voice comparison of the same brand.
isSame: (a, b) => isSame: (a, b) =>
a.query === b.query && a.query === b.query &&
a.competitors.join(",") === b.competitors.join(","), a.competitors.join(",") === b.competitors.join(",") &&
a.scope === b.scope,
}); });
} }

View File

@ -1,13 +1,17 @@
import { z } from "zod"; import { z } from "zod";
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore"; import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
import {
researchScopeSchema,
type ResearchScope,
} from "@/shared/researchScope";
type DomainSortMode = "rank" | "traffic" | "volume" | "score" | "cpc"; type DomainSortMode = "rank" | "traffic" | "volume" | "score" | "cpc";
type DomainTab = "keywords" | "pages"; type DomainTab = "keywords" | "pages";
export interface DomainSearchHistoryItem { export interface DomainSearchHistoryItem {
domain: string; domain: string;
subdomains: boolean; scope: ResearchScope;
sort: DomainSortMode; sort: DomainSortMode;
tab: DomainTab; tab: DomainTab;
locationCode?: number; locationCode?: number;
@ -18,14 +22,24 @@ type AddDomainSearchInput = Omit<DomainSearchHistoryItem, "timestamp">;
const MAX_HISTORY = 20; const MAX_HISTORY = 20;
const domainSearchHistoryItemSchema = z.object({ const domainSearchHistoryItemSchema = z
.object({
domain: z.string(), domain: z.string(),
subdomains: z.boolean(), scope: researchScopeSchema.optional(),
/** Legacy field: pre-scope history encoded "Include subdomains" here. */
subdomains: z.boolean().optional(),
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]), sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
tab: z.enum(["keywords", "pages"]), tab: z.enum(["keywords", "pages"]),
locationCode: z.number().int().positive().optional(), locationCode: z.number().int().positive().optional(),
timestamp: z.number(), timestamp: z.number(),
}); })
.transform(
({ subdomains, scope, ...item }): DomainSearchHistoryItem => ({
...item,
// Legacy items always carried the boolean, so scope-less means true.
scope: scope ?? (subdomains === false ? "domain" : "subdomains"),
}),
);
const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema); const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema);
const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema); const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema);
@ -36,7 +50,7 @@ function isSameSearch(
): boolean { ): boolean {
return ( return (
a.domain === b.domain && a.domain === b.domain &&
a.subdomains === b.subdomains && a.scope === b.scope &&
a.sort === b.sort && a.sort === b.sort &&
a.tab === b.tab && a.tab === b.tab &&
a.locationCode === b.locationCode a.locationCode === b.locationCode

View File

@ -1,10 +1,10 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage"; import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage";
import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope";
import { import {
DEFAULT_BACKLINKS_PAGE_SIZE, DEFAULT_BACKLINKS_PAGE_SIZE,
backlinksSearchSchema, backlinksSearchSchema,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { defaultScopeForInput } from "@/shared/researchScope";
export const Route = createFileRoute("/_project/p/$projectId/backlinks")({ export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
validateSearch: backlinksSearchSchema, validateSearch: backlinksSearchSchema,
@ -24,7 +24,7 @@ function BacklinksRoute() {
order, order,
view, view,
} = Route.useSearch(); } = Route.useSearch();
const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target); const scope = rawScope ?? defaultScopeForInput(target);
return ( return (
<BacklinksPage <BacklinksPage
@ -33,7 +33,8 @@ function BacklinksRoute() {
searchState={{ searchState={{
target, target,
scope, scope,
tab, // Referring domains can't be filtered to a subfolder.
tab: scope === "subfolder" && tab === "domains" ? "backlinks" : tab,
page, page,
pageSize: size, pageSize: size,
sort, sort,

View File

@ -11,14 +11,15 @@ function BrandLookupRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
// `c` is already an opaque competitor string array via the schema transform. // `c` is already an opaque competitor string array via the schema transform.
const { q = "", c = [] } = Route.useSearch(); const { q = "", c = [], scope } = Route.useSearch();
return ( return (
<BrandLookupPage <BrandLookupPage
projectId={projectId} projectId={projectId}
initialQuery={q} initialQuery={q}
initialCompetitors={c} initialCompetitors={c}
onSearchChange={(nextQuery, nextCompetitors) => { initialScope={scope}
onSearchChange={(nextQuery, nextCompetitors, nextScope) => {
void navigate({ void navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
@ -28,6 +29,9 @@ function BrandLookupRoute() {
nextCompetitors.length > 0 nextCompetitors.length > 0
? nextCompetitors.join(",") ? nextCompetitors.join(",")
: undefined, : undefined,
// The page passes a scope only when it differs from the default
// derived from `q`.
scope: nextScope,
}), }),
replace: true, replace: true,
}); });

View File

@ -13,7 +13,6 @@ import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
const DEFAULT_DOMAIN_SEARCH = { const DEFAULT_DOMAIN_SEARCH = {
domain: "", domain: "",
subdomains: true,
sort: "traffic", sort: "traffic",
order: undefined, order: undefined,
tab: "keywords", tab: "keywords",

View File

@ -25,8 +25,18 @@ vi.mock("@/server/lib/dataforseo", () => {
CHATGPT_LANGUAGE_CODE: "en", CHATGPT_LANGUAGE_CODE: "en",
CHATGPT_LOCATION_CODE: 2840, CHATGPT_LOCATION_CODE: 2840,
buildLlmTarget: vi.fn( buildLlmTarget: vi.fn(
({ type, value }: { type: "domain" | "keyword"; value: string }) => ({
type === "domain" ? { domain: value } : { keyword: value }, type,
value,
includeSubdomains,
}: {
type: "domain" | "keyword";
value: string;
includeSubdomains?: boolean;
}) =>
type === "domain"
? { domain: value, include_subdomains: includeSubdomains ?? true }
: { keyword: value },
), ),
createDataforseoClient: vi.fn(() => dataforseoClientMock), createDataforseoClient: vi.fn(() => dataforseoClientMock),
}; };
@ -89,6 +99,7 @@ function baseArgs(overrides: Partial<ShapeArgs>): ShapeArgs {
return { return {
query: "acme", query: "acme",
detected: { type: "keyword", value: "acme" }, detected: { type: "keyword", value: "acme" },
researchTarget: null,
platformBundles: [ platformBundles: [
platformBundle("chat_gpt", 10, 100), platformBundle("chat_gpt", 10, 100),
platformBundle("google", 5, 50), platformBundle("google", 5, 50),
@ -208,6 +219,37 @@ describe("getBrandLookup", () => {
dataforseoClientMock.aiSearch.aggregatedMetrics, dataforseoClientMock.aiSearch.aggregatedMetrics,
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled();
}); });
it("drops subdomains and keys the cache on scope for a URL query", async () => {
resetBrandLookupMocks();
const result = await getBrandLookup(
{
projectId: "project_123",
query: "https://acme.com/blog",
competitors: [],
locationCode: 2840,
languageCode: "en",
},
billingCustomer,
);
// No URL-level targeting exists upstream: the call is domain-only with
// subdomains excluded, and page rows are filtered in shaping.
expect(
dataforseoClientMock.aiSearch.aggregatedMetrics,
).toHaveBeenCalledWith(
expect.objectContaining({
target: { domain: "acme.com", include_subdomains: false },
}),
);
expect(cacheMock.buildCacheKey).toHaveBeenCalledWith(
"ai-search:brand-lookup",
expect.objectContaining({ scope: "subfolder", path: "/blog" }),
);
expect(result.resolvedTarget).toBe("acme.com/blog");
expect(result.aggregatesAreDomainLevel).toBe(true);
});
}); });
describe("resolveCompetitorGroups", () => { describe("resolveCompetitorGroups", () => {

View File

@ -26,6 +26,10 @@ import {
type BrandLookupResult, type BrandLookupResult,
} from "@/types/schemas/ai-search"; } from "@/types/schemas/ai-search";
import { detectTarget } from "@/shared/targetDetection"; import { detectTarget } from "@/shared/targetDetection";
import {
parseResearchTarget,
type ResearchTarget,
} from "@/shared/researchScope";
/** /**
* Brand Lookup is the AI-search analog of Domain Overview. The user types a * Brand Lookup is the AI-search analog of Domain Overview. The user types a
@ -49,6 +53,11 @@ export async function getBrandLookup(
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
): Promise<BrandLookupResult> { ): Promise<BrandLookupResult> {
const detected = detectTarget(input.query); const detected = detectTarget(input.query);
const researchTarget = resolveResearchTarget(input, detected);
// The LLM mentions API only scopes a domain target by subdomain inclusion;
// exact_url/subfolder are honored by post-filtering page rows in shaping.
const includeSubdomains =
researchTarget === null || researchTarget.scope === "subdomains";
const competitorGroups = resolveCompetitorGroups( const competitorGroups = resolveCompetitorGroups(
detected.value, detected.value,
input.competitors, input.competitors,
@ -71,6 +80,16 @@ export async function getBrandLookup(
.join("|"), .join("|"),
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
// Scope changes both the provider call (include_subdomains) and the
// page-level filtering, so it must not share a cache entry. The path only
// affects output under URL scopes — keying it for domain/subdomains would
// re-buy identical fan-outs for example.com vs example.com/blog.
scope: researchTarget?.scope ?? null,
path:
researchTarget?.scope === "exact_url" ||
researchTarget?.scope === "subfolder"
? researchTarget.path
: "",
}); });
const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey)); const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey));
@ -78,7 +97,7 @@ export async function getBrandLookup(
return { return {
...cached.data, ...cached.data,
query: input.query, query: input.query,
resolvedTarget: detected.value, resolvedTarget: researchTarget?.display ?? detected.value,
}; };
} }
@ -92,7 +111,13 @@ export async function getBrandLookup(
for (const platform of PLATFORMS) { for (const platform of PLATFORMS) {
settled.push( settled.push(
await settle(() => await settle(() =>
fetchPlatformData(platform, detected, input, dataforseo), fetchPlatformData(
platform,
detected,
includeSubdomains,
input,
dataforseo,
),
), ),
); );
} }
@ -102,7 +127,13 @@ export async function getBrandLookup(
const crossSettled = const crossSettled =
competitorGroups.length > 0 competitorGroups.length > 0
? await settle(() => ? await settle(() =>
fetchCrossAggregated(detected, competitorGroups, input, dataforseo), fetchCrossAggregated(
detected,
competitorGroups,
includeSubdomains,
input,
dataforseo,
),
) )
: ({ status: "fulfilled", value: [] } as PromiseFulfilledResult< : ({ status: "fulfilled", value: [] } as PromiseFulfilledResult<
CrossOutcome[] CrossOutcome[]
@ -125,6 +156,7 @@ export async function getBrandLookup(
const result = shapeResult({ const result = shapeResult({
query: input.query, query: input.query,
detected, detected,
researchTarget,
platformBundles, platformBundles,
crossOutcomes, crossOutcomes,
competitorKeys: competitorGroups.map((g) => g.label), competitorKeys: competitorGroups.map((g) => g.label),
@ -151,6 +183,26 @@ export async function getBrandLookup(
return result; return result;
} }
/**
* Scopes only apply to domain/URL queries a brand keyword has no URL to
* narrow. A domain the parser rejects (fake TLD) keeps today's unscoped
* behavior and fails downstream with the provider's own validation error.
*/
function resolveResearchTarget(
input: BrandLookupInput,
detected: ReturnType<typeof detectTarget>,
): ResearchTarget | null {
if (detected.type !== "domain") return null;
const parsed = parseResearchTarget(input.query, input.scope);
if (!parsed.ok) {
// An explicit scope that doesn't fit the input (Subfolder without a path)
// must error, not silently run an unscoped lookup.
if (input.scope) throw new AppError("VALIDATION_ERROR", parsed.message);
return null;
}
return parsed.target;
}
async function settle<T>( async function settle<T>(
execute: () => Promise<T>, execute: () => Promise<T>,
): Promise<PromiseSettledResult<T>> { ): Promise<PromiseSettledResult<T>> {
@ -169,12 +221,14 @@ type PlatformFetchInput = Pick<
async function fetchPlatformData( async function fetchPlatformData(
platform: LlmPlatform, platform: LlmPlatform,
detected: ReturnType<typeof detectTarget>, detected: ReturnType<typeof detectTarget>,
includeSubdomains: boolean,
input: PlatformFetchInput, input: PlatformFetchInput,
dataforseo: ReturnType<typeof createDataforseoClient>, dataforseo: ReturnType<typeof createDataforseoClient>,
): Promise<PlatformBundle> { ): Promise<PlatformBundle> {
const target = buildLlmTarget({ const target = buildLlmTarget({
type: detected.type, type: detected.type,
value: detected.value, value: detected.value,
includeSubdomains,
}); });
// ChatGPT mentions DB only contains US/en data per DataForSEO docs. // ChatGPT mentions DB only contains US/en data per DataForSEO docs.
@ -240,23 +294,33 @@ async function fetchPlatformData(
* single failure doesn't discard the other matching the per-platform * single failure doesn't discard the other matching the per-platform
* fan-out in {@link getBrandLookup}. The target's aggregation_key is the * fan-out in {@link getBrandLookup}. The target's aggregation_key is the
* resolved target value so SoV can flag the target row. * resolved target value so SoV can flag the target row.
*
* Share of Voice always compares domain against domain the provider has no
* URL-level targeting so every group (target and competitors) uses the same
* subdomain rule and the UI labels the section domain-level under URL scopes.
*/ */
async function fetchCrossAggregated( async function fetchCrossAggregated(
detected: ReturnType<typeof detectTarget>, detected: ReturnType<typeof detectTarget>,
competitors: CompetitorGroup[], competitors: CompetitorGroup[],
includeSubdomains: boolean,
input: PlatformFetchInput, input: PlatformFetchInput,
dataforseo: ReturnType<typeof createDataforseoClient>, dataforseo: ReturnType<typeof createDataforseoClient>,
): Promise<CrossOutcome[]> { ): Promise<CrossOutcome[]> {
const groups = [ const groups = [
{ {
key: detected.value, key: detected.value,
target: buildLlmTarget({ type: detected.type, value: detected.value }), target: buildLlmTarget({
type: detected.type,
value: detected.value,
includeSubdomains,
}),
}, },
...competitors.map((competitor) => ({ ...competitors.map((competitor) => ({
key: competitor.label, key: competitor.label,
target: buildLlmTarget({ target: buildLlmTarget({
type: competitor.detected.type, type: competitor.detected.type,
value: competitor.detected.value, value: competitor.detected.value,
includeSubdomains,
}), }),
})), })),
]; ];

View File

@ -6,6 +6,7 @@ import type {
LlmTopPagesItem, LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas"; } from "@/server/lib/dataforseoLlmSchemas";
import { brandLookupResultSchema } from "@/types/schemas/ai-search"; import { brandLookupResultSchema } from "@/types/schemas/ai-search";
import { parseResearchTarget } from "@/shared/researchScope";
function platformBundle( function platformBundle(
platform: "chat_gpt" | "google", platform: "chat_gpt" | "google",
@ -46,6 +47,7 @@ function baseArgs(overrides: Partial<ShapeArgs> = {}): ShapeArgs {
return { return {
query: "acme", query: "acme",
detected: { type: "keyword", value: "acme" }, detected: { type: "keyword", value: "acme" },
researchTarget: null,
platformBundles: [ platformBundles: [
platformBundle("chat_gpt", 10, 100), platformBundle("chat_gpt", 10, 100),
platformBundle("google", 5, 50), platformBundle("google", 5, 50),
@ -163,4 +165,64 @@ describe("shapeResult", () => {
}); });
expect(brandLookupResultSchema.safeParse(result).success).toBe(true); expect(brandLookupResultSchema.safeParse(result).success).toBe(true);
}); });
it("keeps only in-scope page rows and prompts under a subfolder scope", () => {
const parsed = parseResearchTarget("acme.com/blog", "subfolder");
if (!parsed.ok) throw new Error(parsed.message);
const result = shapeResult(
baseArgs({
detected: { type: "domain", value: "acme.com" },
researchTarget: parsed.target,
platformBundles: [
{
platform: "google",
status: "success",
bundle: {
aggregated: {
platform: [
{ key: "google", mentions: 12, ai_search_volume: 120 },
],
},
topPages: [
page("https://acme.com/blog/post"),
// Sibling path, and another domain cited alongside the brand.
page("https://acme.com/blogging"),
page("https://other.example/review"),
],
mentions: [
mention("in scope", "https://acme.com/blog/post"),
mention("out of scope", "https://acme.com/pricing"),
],
complete: true,
},
},
],
}),
);
expect(result.topPages.map((row) => row.url)).toEqual([
"https://acme.com/blog/post",
]);
expect(result.topQueries.map((row) => row.question)).toEqual(["in scope"]);
// Mentions can't be narrowed upstream, so they stay and get flagged.
expect(result.totalMentions).toBe(12);
expect(result.aggregatesAreDomainLevel).toBe(true);
expect(result.resolvedTarget).toBe("acme.com/blog");
}); });
});
function page(url: string): LlmTopPagesItem {
return {
key: url,
platform: [{ key: "google", mentions: 2, ai_search_volume: 20 }],
};
}
function mention(question: string, sourceUrl: string): LlmMentionItem {
return {
question,
ai_search_volume: 10,
sources: [{ url: sourceUrl }],
};
}

View File

@ -19,6 +19,10 @@ import {
} from "@/server/features/ai-search/services/shareOfVoice"; } from "@/server/features/ai-search/services/shareOfVoice";
import type { BrandLookupResult } from "@/types/schemas/ai-search"; import type { BrandLookupResult } from "@/types/schemas/ai-search";
import type { detectTarget } from "@/shared/targetDetection"; import type { detectTarget } from "@/shared/targetDetection";
import {
urlMatchesResearchTarget,
type ResearchTarget,
} from "@/shared/researchScope";
const TOP_QUERIES_PER_PLATFORM = 25; const TOP_QUERIES_PER_PLATFORM = 25;
const TOP_SOURCES_PER_PLATFORM = 10; const TOP_SOURCES_PER_PLATFORM = 10;
@ -45,6 +49,8 @@ export type PlatformOutcome = {
export type ShapeArgs = { export type ShapeArgs = {
query: string; query: string;
detected: ReturnType<typeof detectTarget>; detected: ReturnType<typeof detectTarget>;
/** Resolved research target for domain queries; null for brand keywords. */
researchTarget: ResearchTarget | null;
platformBundles: PlatformOutcome[]; platformBundles: PlatformOutcome[];
crossOutcomes: CrossOutcome[]; crossOutcomes: CrossOutcome[];
/** Labels of the resolved competitor groups, as sent to cross_aggregated. */ /** Labels of the resolved competitor groups, as sent to cross_aggregated. */
@ -54,6 +60,11 @@ export type ShapeArgs = {
}; };
export function shapeResult(args: ShapeArgs): BrandLookupResult { export function shapeResult(args: ShapeArgs): BrandLookupResult {
// Post-filter page URLs only for URL scopes; domain/subdomains were already
// scoped in the provider call, and brand-keyword queries have no target.
const scope = args.researchTarget?.scope;
const pageFilter =
scope === "exact_url" || scope === "subfolder" ? args.researchTarget : null;
const successfulBundles = args.platformBundles.filter( const successfulBundles = args.platformBundles.filter(
(b): b is PlatformOutcome & { bundle: PlatformBundle } => (b): b is PlatformOutcome & { bundle: PlatformBundle } =>
b.status === "success" && b.bundle !== null, b.status === "success" && b.bundle !== null,
@ -104,9 +115,10 @@ export function shapeResult(args: ShapeArgs): BrandLookupResult {
sourcesPerPlatform: TOP_SOURCES_PER_PLATFORM, sourcesPerPlatform: TOP_SOURCES_PER_PLATFORM,
keywordsPerSource: KEYWORDS_PER_SOURCE, keywordsPerSource: KEYWORDS_PER_SOURCE,
}, },
pageFilter,
); );
const topQueries = shapeTopQueries(successfulBundles); const topQueries = shapeTopQueries(successfulBundles, pageFilter);
const trendBundles = chatGptLocaleMatches const trendBundles = chatGptLocaleMatches
? successfulBundles ? successfulBundles
: successfulBundles.filter((b) => b.platform !== "chat_gpt"); : successfulBundles.filter((b) => b.platform !== "chat_gpt");
@ -129,7 +141,9 @@ export function shapeResult(args: ShapeArgs): BrandLookupResult {
return { return {
query: args.query, query: args.query,
detectedTargetType: args.detected.type, detectedTargetType: args.detected.type,
resolvedTarget: args.detected.value, resolvedTarget: args.researchTarget?.display ?? args.detected.value,
scope: args.researchTarget?.scope ?? null,
aggregatesAreDomainLevel: pageFilter !== null,
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
hasData, hasData,
totalMentions, totalMentions,
@ -144,6 +158,7 @@ export function shapeResult(args: ShapeArgs): BrandLookupResult {
function shapeTopQueries( function shapeTopQueries(
bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>, bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>,
pageFilter: ResearchTarget | null,
): BrandLookupResult["topQueries"] { ): BrandLookupResult["topQueries"] {
return sortBy( return sortBy(
bundles.flatMap((bundle) => bundles.flatMap((bundle) =>
@ -153,6 +168,17 @@ function shapeTopQueries(
(item): item is LlmMentionItem & { question: string } => (item): item is LlmMentionItem & { question: string } =>
typeof item.question === "string" && item.question.length > 0, typeof item.question === "string" && item.question.length > 0,
) )
// Under a URL scope a prompt only counts when the answer actually
// cited a page in scope — a brand named in the answer text is
// domain-level evidence we must not attribute to the page.
.filter(
(item) =>
pageFilter === null ||
(item.sources ?? []).some((source) => {
const url = safeHttpUrl(source.url);
return url != null && urlMatchesResearchTarget(url, pageFilter);
}),
)
.map((item) => ({ .map((item) => ({
question: truncate(item.question, MAX_QUESTION_LENGTH), question: truncate(item.question, MAX_QUESTION_LENGTH),
platform: bundle.platform, platform: bundle.platform,

View File

@ -7,6 +7,10 @@ import type {
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl"; import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl";
import { roundOrNull } from "@/server/features/ai-search/services/shareOfVoice"; import { roundOrNull } from "@/server/features/ai-search/services/shareOfVoice";
import type { BrandLookupResult } from "@/types/schemas/ai-search"; import type { BrandLookupResult } from "@/types/schemas/ai-search";
import {
urlMatchesResearchTarget,
type ResearchTarget,
} from "@/shared/researchScope";
type Bundle = { type Bundle = {
platform: LlmPlatform; platform: LlmPlatform;
@ -24,10 +28,15 @@ const MAX_QUESTION_LENGTH = 500;
* examples from the mentions sample when the exact cited URL appears there. * examples from the mentions sample when the exact cited URL appears there.
* The page metrics stay authoritative while the prompt examples remain plainly * The page metrics stay authoritative while the prompt examples remain plainly
* sample-based. * sample-based.
*
* `pageFilter` narrows the rows to a URL-scoped research target (the provider
* has no URL-level targeting). Filtering happens before the per-platform cap so
* in-scope pages can't be crowded out by out-of-scope ones.
*/ */
export function deriveCitedSources( export function deriveCitedSources(
bundles: Bundle[], bundles: Bundle[],
limits: { sourcesPerPlatform: number; keywordsPerSource: number }, limits: { sourcesPerPlatform: number; keywordsPerSource: number },
pageFilter: ResearchTarget | null = null,
): BrandLookupResult["topPages"] { ): BrandLookupResult["topPages"] {
const promptExamples = buildPromptExamples(bundles); const promptExamples = buildPromptExamples(bundles);
@ -36,6 +45,9 @@ export function deriveCitedSources(
.map((page) => { .map((page) => {
const url = safeHttpUrl(page.key); const url = safeHttpUrl(page.key);
if (!url || url.length > MAX_URL_LENGTH) return null; if (!url || url.length > MAX_URL_LENGTH) return null;
if (pageFilter && !urlMatchesResearchTarget(url, pageFilter)) {
return null;
}
const platformGroup = page.platform?.find( const platformGroup = page.platform?.find(
(entry) => entry.key === bundle.platform, (entry) => entry.key === bundle.platform,
); );

View File

@ -37,6 +37,19 @@ const billingCustomer = {
userEmail: "team@example.com", userEmail: "team@example.com",
}; };
function mockTarget(
overrides: Partial<ReturnType<typeof normalizeBacklinksTarget>> = {},
) {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
includeSubdomains: false,
path: "",
...overrides,
});
}
const pageInputDefaults = { const pageInputDefaults = {
projectId: "project_123", projectId: "project_123",
page: 1, page: 1,
@ -63,11 +76,7 @@ beforeEach(() => {
}); });
it("profiles only the summary and history for the overview and reuses cache on repeat", async () => { it("profiles only the summary and history for the overview and reuses cache on repeat", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ mockTarget();
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksSummaryMock.mockResolvedValue({ backlinksSummaryMock.mockResolvedValue({
rank: 42, rank: 42,
backlinks: 1200, backlinks: 1200,
@ -115,11 +124,7 @@ it("profiles only the summary and history for the overview and reuses cache on r
}); });
it("profiles backlink rows per page with offset and total count", async () => { it("profiles backlink rows per page with offset and total count", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ mockTarget();
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksRowsMock.mockResolvedValue({ backlinksRowsMock.mockResolvedValue({
items: [ items: [
{ {
@ -172,11 +177,7 @@ it("profiles backlink rows per page with offset and total count", async () => {
}); });
it("translates filters into DataForSEO conditions for backlink rows", async () => { it("translates filters into DataForSEO conditions for backlink rows", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ mockTarget();
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 }); backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
await service.profileBacklinksPage( await service.profileBacklinksPage(
@ -211,10 +212,11 @@ it("translates filters into DataForSEO conditions for backlink rows", async () =
}); });
it("profiles referring domains and top pages pages separately", async () => { it("profiles referring domains and top pages pages separately", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ mockTarget({
apiTarget: "https://example.com/foo", apiTarget: "https://example.com/foo",
displayTarget: "https://example.com/foo", displayTarget: "https://example.com/foo",
scope: "page", scope: "exact_url",
includeSubdomains: true,
}); });
referringDomainsMock.mockResolvedValue({ referringDomainsMock.mockResolvedValue({
items: [ items: [
@ -269,11 +271,7 @@ it("profiles referring domains and top pages pages separately", async () => {
}); });
it("does not fall back to target spam score for referring domains", async () => { it("does not fall back to target spam score for referring domains", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ mockTarget();
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
referringDomainsMock.mockResolvedValue({ referringDomainsMock.mockResolvedValue({
items: [ items: [
{ {
@ -304,12 +302,8 @@ it("does not fall back to target spam score for referring domains", async () =>
expect(domains.rows[0]?.spamScore).toBeNull(); expect(domains.rows[0]?.spamScore).toBeNull();
}); });
it("keeps page cache entries isolated per page and per organization", async () => { it("keeps page cache entries isolated per page, organization, and scope", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({ mockTarget();
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 }); backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
const input = { const input = {
@ -331,6 +325,14 @@ it("keeps page cache entries isolated per page and per organization", async () =
userEmail: "other@example.com", userEmail: "other@example.com",
}); });
expect(backlinksRowsMock).toHaveBeenCalledTimes(3); expect(backlinksRowsMock).toHaveBeenCalledTimes(3);
// Same hostname, subdomains included: a different result set, not a cache hit.
mockTarget({ scope: "subdomains", includeSubdomains: true });
await service.profileBacklinksPage(
{ ...input, scope: "subdomains" },
billingCustomer,
);
expect(backlinksRowsMock).toHaveBeenCalledTimes(4);
}); });
function parseCachedValue(raw: string): unknown { function parseCachedValue(raw: string): unknown {
@ -340,3 +342,49 @@ function parseCachedValue(raw: string): unknown {
return null; return null;
} }
} }
it("builds subfolder overview totals from two filtered backlink counts", async () => {
mockTarget({
displayTarget: "example.com/blog",
scope: "subfolder",
path: "/blog",
});
backlinksRowsMock
.mockResolvedValueOnce({ items: [], totalCount: 2500 })
.mockResolvedValueOnce({ items: [], totalCount: 180 });
const { overview } = await service.profileOverview(
{ target: "example.com/blog", scope: "subfolder" },
billingCustomer,
);
expect(overview.summary.backlinks).toBe(2500);
expect(overview.summary.referringDomains).toBe(180);
expect(overview.summary.rank).toBeNull();
expect(overview.trends).toEqual([]);
expect(backlinksSummaryMock).not.toHaveBeenCalled();
expect(backlinksHistoryMock).not.toHaveBeenCalled();
expect(backlinksRowsMock).toHaveBeenCalledTimes(2);
expect(backlinksRowsMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
mode: "as_is",
limit: 1,
filters: [
[
["url_to", "like", "%://example.com/blog"],
"or",
["url_to", "like", "%://example.com/blog/%"],
"or",
["url_to", "like", "%://www.example.com/blog"],
"or",
["url_to", "like", "%://www.example.com/blog/%"],
],
],
}),
);
expect(backlinksRowsMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ mode: "one_per_domain", limit: 1 }),
);
});

View File

@ -25,7 +25,7 @@ const defaultCache: BacklinksCache = {
type BacklinksPageCacheInput = { type BacklinksPageCacheInput = {
target: string; target: string;
scope?: "domain" | "page"; scope?: BacklinksLookupInput["scope"];
page: number; page: number;
pageSize: number; pageSize: number;
sortField: string; sortField: string;
@ -124,6 +124,12 @@ function buildTargetCacheInput(
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
target: normalizedTarget.apiTarget, target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope, scope: normalizedTarget.scope,
// Subfolder scope keeps the hostname as the API target, so the path must
// separate cache entries.
path: normalizedTarget.path,
// Same hostname, different result set — and keeping it in the key retires
// entries written before scopes could exclude subdomains.
includeSubdomains: normalizedTarget.includeSubdomains,
}; };
} }

View File

@ -56,7 +56,7 @@ const backlinksNewLostTrendRowSchema = z.object({
export const backlinksOverviewSchema = z.object({ export const backlinksOverviewSchema = z.object({
target: z.string(), target: z.string(),
displayTarget: z.string(), displayTarget: z.string(),
scope: z.enum(["domain", "page"]), scope: z.enum(["exact_url", "subfolder", "domain", "subdomains"]),
summary: z.object({ summary: z.object({
rank: z.number().nullable(), rank: z.number().nullable(),
backlinks: z.number().nullable(), backlinks: z.number().nullable(),

View File

@ -0,0 +1,73 @@
import type {
BacklinksItem,
DomainPageSummaryItem,
ReferringDomainItem,
} from "@/server/lib/dataforseo";
export function normalizeHistoryDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : null;
}
export function mapBacklinksRows(rows: BacklinksItem[]) {
return rows.map((item) => ({
domainFrom: item.domain_from ?? null,
urlFrom: item.url_from ?? null,
urlTo: item.url_to ?? null,
anchor: item.anchor ?? null,
itemType: item.item_type ?? null,
isDofollow: item.dofollow ?? null,
relAttributes: item.rel_attributes ?? item.attributes ?? [],
rank: item.rank ?? null,
domainFromRank: item.domain_from_rank ?? null,
pageFromRank: item.page_from_rank ?? null,
spamScore: item.backlink_spam_score ?? item.backlinks_spam_score ?? null,
firstSeen: item.first_seen ?? null,
lastSeen: item.lost_date ?? item.last_visited ?? null,
isLost: item.is_lost ?? Boolean(item.lost_date),
isBroken: item.is_broken ?? false,
linksCount: item.links_count ?? null,
}));
}
export function mapReferringDomainsRows(rows: ReferringDomainItem[]) {
return rows.map((item) => ({
domain: item.domain ?? null,
backlinks: item.backlinks ?? null,
referringPages: item.referring_pages ?? null,
rank: item.rank ?? null,
spamScore: item.backlinks_spam_score ?? null,
firstSeen: item.first_seen ?? null,
brokenBacklinks: item.broken_backlinks ?? null,
brokenPages: item.broken_pages ?? null,
}));
}
export function mapTopPagesRows(rows: DomainPageSummaryItem[]) {
return rows.map((item) => ({
page: item.page ?? item.url ?? null,
backlinks: item.backlinks ?? null,
referringDomains: item.referring_domains ?? null,
rank: item.rank ?? null,
brokenBacklinks: item.broken_backlinks ?? null,
}));
}
export function buildPageResult<TRow>(
input: { page: number; pageSize: number },
offset: number,
data: { rows: TRow[]; totalCount: number | null },
) {
const hasMore =
data.totalCount != null
? offset + data.rows.length < data.totalCount
: data.rows.length === input.pageSize;
return {
rows: data.rows,
totalCount: data.totalCount,
hasMore,
page: input.page,
pageSize: input.pageSize,
fetchedAt: new Date().toISOString(),
};
}

View File

@ -5,17 +5,15 @@ import {
createDataforseoClient, createDataforseoClient,
normalizeBacklinksTarget, normalizeBacklinksTarget,
type BacklinksHistoryItem, type BacklinksHistoryItem,
type BacklinksItem,
type BacklinksSummaryItem, type BacklinksSummaryItem,
type DomainPageSummaryItem,
type ReferringDomainItem,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import type { import {
BacklinksLookupInput, normalizeBacklinksSpamFilterOptions,
BacklinksRowsPageInput, type BacklinksLookupInput,
BacklinksSpamFilterOptions, type BacklinksRowsPageInput,
ReferringDomainsPageInput, type BacklinksSpamFilterOptions,
TopPagesPageInput, type ReferringDomainsPageInput,
type TopPagesPageInput,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { import {
@ -36,6 +34,21 @@ import {
buildTopPagesApiFilters, buildTopPagesApiFilters,
buildTopPagesOrderBy, buildTopPagesOrderBy,
} from "@/server/features/backlinks/services/backlinksApiFilters"; } from "@/server/features/backlinks/services/backlinksApiFilters";
import {
buildBacklinksScopeFilter,
countExpressionConditions,
prependScopeClauses,
} from "@/server/lib/dataforseo/researchScopeFilters";
import { buildSubfolderOverview } from "@/server/features/backlinks/services/backlinksSubfolderOverview";
import {
buildPageResult,
mapBacklinksRows,
mapReferringDomainsRows,
mapTopPagesRows,
normalizeHistoryDate,
} from "@/server/features/backlinks/services/backlinksRowMappers";
import { assertFilterConditionBudget } from "@/server/lib/dataforseo/filters";
import { AppError } from "@/server/lib/errors";
// The page-request schemas carry projectId for the web middleware; the // The page-request schemas carry projectId for the web middleware; the
// service layer is organization-scoped and never reads it. // service layer is organization-scoped and never reads it.
@ -61,10 +74,6 @@ export type BacklinksCache = {
set(key: string, data: unknown, ttlSeconds: number): Promise<void>; set(key: string, data: unknown, ttlSeconds: number): Promise<void>;
}; };
type BacklinksOverviewProfile = {
overview: BacklinksOverviewResult;
};
type BacklinksDateRange = { type BacklinksDateRange = {
dateFrom: string; dateFrom: string;
dateTo: string; dateTo: string;
@ -76,7 +85,7 @@ export async function profileBacklinksOverview(
input: BacklinksLookupInput, input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
): Promise<BacklinksOverviewProfile> { ): Promise<{ overview: BacklinksOverviewResult }> {
const cached = backlinksOverviewCacheSchema.safeParse( const cached = backlinksOverviewCacheSchema.safeParse(
await cache.get(cacheKey), await cache.get(cacheKey),
); );
@ -92,20 +101,40 @@ export async function profileBacklinksOverview(
const normalizedTarget = normalizeBacklinksTarget(input.target, { const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope, scope: input.scope,
}); });
if (normalizedTarget.scope === "subfolder") {
const overview = await buildSubfolderOverview(
dataforseo,
normalizedTarget,
now,
creditFeature,
);
await cacheValue(
cache,
cacheKey,
{ overview },
BACKLINKS_OVERVIEW_TTL_SECONDS,
);
return { overview };
}
const dateRange = buildBacklinksDateRange(now); const dateRange = buildBacklinksDateRange(now);
const [summary, history] = await Promise.all([ const [summary, history] = await Promise.all([
dataforseo.backlinks.summary({ dataforseo.backlinks.summary({
target: normalizedTarget.apiTarget, target: normalizedTarget.apiTarget,
includeSubdomains: normalizedTarget.includeSubdomains,
creditFeature, creditFeature,
}), }),
normalizedTarget.scope === "domain" // history/live only accepts a hostname and has no include_subdomains field,
? dataforseo.backlinks.history({ // so trends are unavailable for a page and subdomain-inclusive otherwise.
normalizedTarget.scope === "exact_url"
? Promise.resolve([])
: dataforseo.backlinks.history({
target: normalizedTarget.apiTarget, target: normalizedTarget.apiTarget,
...dateRange, ...dateRange,
creditFeature, creditFeature,
}) }),
: Promise.resolve([]),
]); ]);
const overview = buildOverviewResult({ const overview = buildOverviewResult({
@ -140,11 +169,22 @@ export async function profileBacklinksRowsPage(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const offset = (input.page - 1) * input.pageSize; const offset = (input.page - 1) * input.pageSize;
const filters = buildBacklinksRowsApiFilters(input.filters);
const target = normalizeBacklinksTarget(input.target, { scope: input.scope });
const scopeFilter = buildBacklinksScopeFilter("url_to", target);
const userFilters = buildBacklinksRowsApiFilters(input.filters);
// The scope group and the server-appended spam condition share the same
// 8-condition budget as user filters.
assertFilterConditionBudget(
scopeFilter.conditionCount +
countExpressionConditions(userFilters) +
(normalizeBacklinksSpamFilterOptions(spamOptions).hideSpam ? 1 : 0),
);
const filters = prependScopeClauses(scopeFilter, userFilters);
const response = await dataforseo.backlinks.rows({ const response = await dataforseo.backlinks.rows({
target: normalizeBacklinksTarget(input.target, { scope: input.scope }) target: target.apiTarget,
.apiTarget, includeSubdomains: target.includeSubdomains,
limit: input.pageSize, limit: input.pageSize,
offset, offset,
orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder), orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder),
@ -180,9 +220,20 @@ export async function profileReferringDomainsPage(
const offset = (input.page - 1) * input.pageSize; const offset = (input.page - 1) * input.pageSize;
const filters = buildReferringDomainsApiFilters(input.filters); const filters = buildReferringDomainsApiFilters(input.filters);
const target = normalizeBacklinksTarget(input.target, { scope: input.scope });
// referring_domains has no URL field to filter on, so subfolder scope has no
// accurate source for this breakdown (the count still comes from the
// overview's filtered totals).
if (target.scope === "subfolder") {
throw new AppError(
"VALIDATION_ERROR",
"Referring domains can't be broken down for a subfolder — use the Backlinks tab, or switch to Domain or Subdomains scope.",
);
}
const response = await dataforseo.backlinks.referringDomains({ const response = await dataforseo.backlinks.referringDomains({
target: normalizeBacklinksTarget(input.target, { scope: input.scope }) target: target.apiTarget,
.apiTarget, includeSubdomains: target.includeSubdomains,
limit: input.pageSize, limit: input.pageSize,
offset, offset,
orderBy: buildReferringDomainsOrderBy(input.sortField, input.sortOrder), orderBy: buildReferringDomainsOrderBy(input.sortField, input.sortOrder),
@ -212,11 +263,18 @@ export async function profileTopPagesPage(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const offset = (input.page - 1) * input.pageSize; const offset = (input.page - 1) * input.pageSize;
const filters = buildTopPagesApiFilters(input.filters);
const target = normalizeBacklinksTarget(input.target, { scope: input.scope });
const scopeFilter = buildBacklinksScopeFilter("url", target);
const userFilters = buildTopPagesApiFilters(input.filters);
assertFilterConditionBudget(
scopeFilter.conditionCount + countExpressionConditions(userFilters),
);
const filters = prependScopeClauses(scopeFilter, userFilters);
const response = await dataforseo.backlinks.domainPages({ const response = await dataforseo.backlinks.domainPages({
target: normalizeBacklinksTarget(input.target, { scope: input.scope }) target: target.apiTarget,
.apiTarget, includeSubdomains: target.includeSubdomains,
limit: input.pageSize, limit: input.pageSize,
offset, offset,
orderBy: buildTopPagesOrderBy(input.sortField, input.sortOrder), orderBy: buildTopPagesOrderBy(input.sortField, input.sortOrder),
@ -232,26 +290,6 @@ export async function profileTopPagesPage(
return result; return result;
} }
function buildPageResult<TRow>(
input: { page: number; pageSize: number },
offset: number,
data: { rows: TRow[]; totalCount: number | null },
) {
const hasMore =
data.totalCount != null
? offset + data.rows.length < data.totalCount
: data.rows.length === input.pageSize;
return {
rows: data.rows,
totalCount: data.totalCount,
hasMore,
page: input.page,
pageSize: input.pageSize,
fetchedAt: new Date().toISOString(),
};
}
function buildBacklinksDateRange(now: Date): BacklinksDateRange { function buildBacklinksDateRange(now: Date): BacklinksDateRange {
const todayUtc = new Date( const todayUtc = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
@ -336,54 +374,6 @@ function buildOverviewResult(args: {
}; };
} }
function normalizeHistoryDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : null;
}
function mapBacklinksRows(rows: BacklinksItem[]) {
return rows.map((item) => ({
domainFrom: item.domain_from ?? null,
urlFrom: item.url_from ?? null,
urlTo: item.url_to ?? null,
anchor: item.anchor ?? null,
itemType: item.item_type ?? null,
isDofollow: item.dofollow ?? null,
relAttributes: item.rel_attributes ?? item.attributes ?? [],
rank: item.rank ?? null,
domainFromRank: item.domain_from_rank ?? null,
pageFromRank: item.page_from_rank ?? null,
spamScore: item.backlink_spam_score ?? item.backlinks_spam_score ?? null,
firstSeen: item.first_seen ?? null,
lastSeen: item.lost_date ?? item.last_visited ?? null,
isLost: item.is_lost ?? Boolean(item.lost_date),
isBroken: item.is_broken ?? false,
linksCount: item.links_count ?? null,
}));
}
function mapReferringDomainsRows(rows: ReferringDomainItem[]) {
return rows.map((item) => ({
domain: item.domain ?? null,
backlinks: item.backlinks ?? null,
referringPages: item.referring_pages ?? null,
rank: item.rank ?? null,
spamScore: item.backlinks_spam_score ?? null,
firstSeen: item.first_seen ?? null,
brokenBacklinks: item.broken_backlinks ?? null,
brokenPages: item.broken_pages ?? null,
}));
}
function mapTopPagesRows(rows: DomainPageSummaryItem[]) {
return rows.map((item) => ({
page: item.page ?? item.url ?? null,
backlinks: item.backlinks ?? null,
referringDomains: item.referring_domains ?? null,
rank: item.rank ?? null,
brokenBacklinks: item.broken_backlinks ?? null,
}));
}
async function cacheValue( async function cacheValue(
cache: BacklinksCache, cache: BacklinksCache,
key: string, key: string,

View File

@ -0,0 +1,63 @@
import type { CreditFeature } from "@/shared/billing-credit-features";
import type {
createDataforseoClient,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseo";
import { buildBacklinksScopeFilter } from "@/server/lib/dataforseo/researchScopeFilters";
import type { BacklinksOverviewResult } from "@/server/features/backlinks/services/backlinksOverviewSchema";
/**
* The summary and history endpoints only take a whole target, so subfolder
* totals come from two filtered `total_count` reads of the backlinks list:
* every link (`as_is`) and one per referring domain. Rank, spam scores,
* new/lost, and trends have no filtered source and stay null/empty.
* Sequenced, not parallel: hosted billing checks balance per call.
*/
export async function buildSubfolderOverview(
dataforseo: ReturnType<typeof createDataforseoClient>,
normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>,
now: Date,
creditFeature?: CreditFeature,
): Promise<BacklinksOverviewResult> {
const filters = buildBacklinksScopeFilter("url_to", normalizedTarget).clauses;
const allLinks = await dataforseo.backlinks.rows({
target: normalizedTarget.apiTarget,
includeSubdomains: normalizedTarget.includeSubdomains,
limit: 1,
mode: "as_is",
filters,
creditFeature,
});
const perDomain = await dataforseo.backlinks.rows({
target: normalizedTarget.apiTarget,
includeSubdomains: normalizedTarget.includeSubdomains,
limit: 1,
mode: "one_per_domain",
filters,
creditFeature,
});
return {
target: normalizedTarget.apiTarget,
displayTarget: normalizedTarget.displayTarget,
scope: "subfolder",
summary: {
rank: null,
backlinks: allLinks.totalCount,
referringPages: null,
referringDomains: perDomain.totalCount,
brokenBacklinks: null,
brokenPages: null,
backlinksSpamScore: null,
targetSpamScore: null,
newBacklinks: null,
lostBacklinks: null,
newReferringDomains: null,
lostReferringDomains: null,
},
trends: [],
newLostTrends: [],
fetchedAt: now.toISOString(),
};
}

View File

@ -243,12 +243,14 @@ async function ensureBacklinkSnapshot(input: {
return getBacklinkSummary(projectId, domain); return getBacklinkSummary(projectId, domain);
} }
const normalized = normalizeBacklinksTarget(domain, { scope: "domain" }); // Dashboard totals cover the whole site, subdomains included.
const normalized = normalizeBacklinksTarget(domain, { scope: "subdomains" });
const dataforseo = createDataforseoClient(input.billingCustomer); const dataforseo = createDataforseoClient(input.billingCustomer);
try { try {
const summary = await dataforseo.backlinks.summary({ const summary = await dataforseo.backlinks.summary({
target: normalized.apiTarget, target: normalized.apiTarget,
includeSubdomains: normalized.includeSubdomains,
}); });
await BacklinkSnapshotRepository.insert({ await BacklinkSnapshotRepository.insert({
projectId, projectId,

View File

@ -4,7 +4,10 @@ import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { CreditFeature } from "@/shared/billing-credit-features"; import type { CreditFeature } from "@/shared/billing-credit-features";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { normalizeDomainInput } from "@/server/lib/domainUtils"; import { buildRankedKeywordsScopeFilter } from "@/server/lib/dataforseo/researchScopeFilters";
import { joinClauses } from "@/server/lib/dataforseo/filters";
import { parseResearchTargetOrThrow } from "@/server/lib/domainUtils";
import type { ResearchScope } from "@/shared/researchScope";
import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper"; import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper";
import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage"; import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage";
import { getPagesPage } from "@/server/features/domain/services/domainPagesPage"; import { getPagesPage } from "@/server/features/domain/services/domainPagesPage";
@ -29,26 +32,33 @@ const domainOverviewResultSchema = z.object({
fetchedAt: z.string(), fetchedAt: z.string(),
}); });
type DomainOverviewResult = z.infer<typeof domainOverviewResultSchema>; type DomainOverviewResult = z.infer<typeof domainOverviewResultSchema> & {
/** Requested research scope, echoed for display. */
scope: ResearchScope;
displayTarget: string;
};
async function getOverview( async function getOverview(
input: { input: {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; scope?: ResearchScope;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
}, },
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
metering: MeteringOverrides = {}, metering: MeteringOverrides = {},
): Promise<DomainOverviewResult> { ): Promise<DomainOverviewResult> {
const domain = normalizeDomainInput(input.domain, input.includeSubdomains); const target = parseResearchTargetOrThrow(input.domain, input.scope);
const domain = target.hostname;
// domain_rank_overview has no filters and always covers the hostname plus
// all of its subdomains, so every scope shares one cache entry per hostname.
// Callers label the metrics as domain-wide for narrower scopes.
const cacheKey = await buildCacheKey("domain:overview", { const cacheKey = await buildCacheKey("domain:overview", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
projectId: input.projectId, projectId: input.projectId,
domain, domain,
includeSubdomains: input.includeSubdomains,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
@ -56,7 +66,11 @@ async function getOverview(
const cachedRaw = await getCached(cacheKey); const cachedRaw = await getCached(cacheKey);
const cached = domainOverviewResultSchema.safeParse(cachedRaw); const cached = domainOverviewResultSchema.safeParse(cachedRaw);
if (cached.success && cached.data.hasData) { if (cached.success && cached.data.hasData) {
return cached.data; return {
...cached.data,
scope: target.scope,
displayTarget: target.display,
};
} }
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
@ -80,7 +94,7 @@ async function getOverview(
? Math.round(metrics.metrics.organic.count) ? Math.round(metrics.metrics.organic.count)
: null; : null;
const result: DomainOverviewResult = { const stored: z.infer<typeof domainOverviewResultSchema> = {
domain, domain,
organicTraffic, organicTraffic,
organicKeywords, organicKeywords,
@ -90,11 +104,11 @@ async function getOverview(
fetchedAt: nowIso, fetchedAt: nowIso,
}; };
if (result.hasData) { if (stored.hasData) {
// waitUntil, not void: workerd cancels unregistered pending I/O once the // waitUntil, not void: workerd cancels unregistered pending I/O once the
// response is sent, so a fire-and-forget put never persists the cache. // response is sent, so a fire-and-forget put never persists the cache.
waitUntil( waitUntil(
setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch( setCached(cacheKey, stored, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
(error) => { (error) => {
console.error("domain.overview.cache-write failed:", error); console.error("domain.overview.cache-write failed:", error);
}, },
@ -102,12 +116,13 @@ async function getOverview(
); );
} }
return result; return { ...stored, scope: target.scope, displayTarget: target.display };
} }
async function getSuggestedKeywords( async function getSuggestedKeywords(
input: { input: {
domain: string; domain: string;
scope?: ResearchScope;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
organizationId: string; organizationId: string;
@ -125,12 +140,15 @@ async function getSuggestedKeywords(
keywordDifficulty: number | null; keywordDifficulty: number | null;
}> }>
> { > {
const domain = normalizeDomainInput(input.domain, true); const target = parseResearchTargetOrThrow(input.domain, input.scope);
const scopeFilter = buildRankedKeywordsScopeFilter(target);
const cacheKey = await buildCacheKey("domain:keyword-suggestions", { const cacheKey = await buildCacheKey("domain:keyword-suggestions", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
projectId: input.projectId, projectId: input.projectId,
domain, domain: target.hostname,
scope: target.scope,
path: target.path,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
@ -155,11 +173,15 @@ async function getSuggestedKeywords(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const rankedKeywordsResponse = await dataforseo.domain.rankedKeywords({ const rankedKeywordsResponse = await dataforseo.domain.rankedKeywords({
target: domain, target: target.hostname,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
limit: 100, limit: 100,
orderBy: ["ranked_serp_element.serp_item.etv,desc"], orderBy: ["ranked_serp_element.serp_item.etv,desc"],
filters:
scopeFilter.clauses.length > 0
? joinClauses(scopeFilter.clauses, "and")
: undefined,
...metering, ...metering,
}); });

View File

@ -6,6 +6,7 @@ import {
parseFilterTerms, parseFilterTerms,
type FilterClause, type FilterClause,
} from "@/server/lib/dataforseo/filters"; } from "@/server/lib/dataforseo/filters";
import type { ScopeFilter } from "@/server/lib/dataforseo/researchScopeFilters";
import type { DomainKeywordsFilters } from "@/types/schemas/domain"; import type { DomainKeywordsFilters } from "@/types/schemas/domain";
export type DomainKeywordsSortMode = export type DomainKeywordsSortMode =
@ -34,12 +35,14 @@ export function buildOrderBy(
/** /**
* Each include/exclude term is one ilike clause; numeric ranges add one per * Each include/exclude term is one ilike clause; numeric ranges add one per
* bound; the free-text search term adds one OR-group of two (keyword OR url). * bound; the free-text search term adds one OR-group of two (keyword OR url).
* The client surfaces the same condition count and disables Apply when over * Scope clauses (research scope narrowing) are ANDed in front and consume
* the DataForSEO budget. * part of the same budget. The client surfaces the same condition count and
* disables Apply when over the DataForSEO budget.
*/ */
export function buildKeywordFilters( export function buildKeywordFilters(
filters: DomainKeywordsFilters, filters: DomainKeywordsFilters,
searchTerm?: string, searchTerm?: string,
scopeFilter?: ScopeFilter,
): unknown[] { ): unknown[] {
const conditions: FilterClause[] = []; const conditions: FilterClause[] = [];
@ -92,11 +95,20 @@ export function buildKeywordFilters(
const trimmedSearch = searchTerm?.trim(); const trimmedSearch = searchTerm?.trim();
const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null; const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null;
// The search OR-group costs 2 slots; everything else is 1. // The search OR-group costs 2 slots; scope clauses cost their reported
assertFilterConditionBudget(conditions.length + (searchGroup ? 2 : 0)); // count; everything else is 1.
assertFilterConditionBudget(
(scopeFilter?.conditionCount ?? 0) +
conditions.length +
(searchGroup ? 2 : 0),
);
return joinClauses( return joinClauses(
searchGroup ? [...conditions, searchGroup] : conditions, [
...(scopeFilter?.clauses ?? []),
...conditions,
...(searchGroup ? [searchGroup] : []),
],
"and", "and",
); );
} }

View File

@ -3,7 +3,9 @@ import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { normalizeDomainInput } from "@/server/lib/domainUtils"; import { parseResearchTargetOrThrow } from "@/server/lib/domainUtils";
import { buildRankedKeywordsScopeFilter } from "@/server/lib/dataforseo/researchScopeFilters";
import type { ResearchScope } from "@/shared/researchScope";
import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper"; import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper";
import { computeHasMore } from "@/server/features/domain/services/pagination"; import { computeHasMore } from "@/server/features/domain/services/pagination";
import { import {
@ -43,7 +45,7 @@ export async function getKeywordsPage(
input: { input: {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; scope?: ResearchScope;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
page: number; page: number;
@ -55,16 +57,18 @@ export async function getKeywordsPage(
}, },
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
): Promise<DomainKeywordsPageResult> { ): Promise<DomainKeywordsPageResult> {
const domain = normalizeDomainInput(input.domain, input.includeSubdomains); const target = parseResearchTargetOrThrow(input.domain, input.scope);
const scopeFilter = buildRankedKeywordsScopeFilter(target);
const offset = (input.page - 1) * input.pageSize; const offset = (input.page - 1) * input.pageSize;
const orderBy = buildOrderBy(input.sortMode, input.sortOrder); const orderBy = buildOrderBy(input.sortMode, input.sortOrder);
const filters = buildKeywordFilters(input.filters, input.search); const filters = buildKeywordFilters(input.filters, input.search, scopeFilter);
const cacheKey = await buildCacheKey("domain:keywords-page", { const cacheKey = await buildCacheKey("domain:keywords-page", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
projectId: input.projectId, projectId: input.projectId,
domain, domain: target.hostname,
includeSubdomains: input.includeSubdomains, scope: target.scope,
path: target.path,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
page: input.page, page: input.page,
@ -83,7 +87,7 @@ export async function getKeywordsPage(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const response = await dataforseo.domain.rankedKeywords({ const response = await dataforseo.domain.rankedKeywords({
target: domain, target: target.hostname,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
limit: input.pageSize, limit: input.pageSize,
@ -108,7 +112,7 @@ export async function getKeywordsPage(
); );
const result: DomainKeywordsPageResult = { const result: DomainKeywordsPageResult = {
domain, domain: target.hostname,
page: input.page, page: input.page,
pageSize: input.pageSize, pageSize: input.pageSize,
totalCount, totalCount,

View File

@ -3,7 +3,16 @@ import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils"; import {
parseResearchTargetOrThrow,
toRelativePath,
} from "@/server/lib/domainUtils";
import {
buildRelevantPagesScopeFilter,
type ScopeFilter,
} from "@/server/lib/dataforseo/researchScopeFilters";
import { assertFilterConditionBudget } from "@/server/lib/dataforseo/filters";
import type { ResearchScope } from "@/shared/researchScope";
import type { RelevantPagesItem } from "@/server/lib/dataforseo"; import type { RelevantPagesItem } from "@/server/lib/dataforseo";
import { computeHasMore } from "@/server/features/domain/services/pagination"; import { computeHasMore } from "@/server/features/domain/services/pagination";
import type { DomainKeywordsFilters } from "@/types/schemas/domain"; import type { DomainKeywordsFilters } from "@/types/schemas/domain";
@ -71,7 +80,8 @@ function parseTerms(value: string | undefined): string[] {
function buildPageFilters( function buildPageFilters(
filters: DomainKeywordsFilters, filters: DomainKeywordsFilters,
searchTerm?: string, searchTerm: string | undefined,
scopeFilter: ScopeFilter,
): unknown[] { ): unknown[] {
const conditions: unknown[][] = []; const conditions: unknown[][] = [];
@ -100,8 +110,12 @@ function buildPageFilters(
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]); conditions.push(["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]);
} }
assertFilterConditionBudget(scopeFilter.conditionCount + conditions.length);
const expressions: unknown[] = []; const expressions: unknown[] = [];
for (const condition of conditions) pushAnd(expressions, condition); for (const condition of [...scopeFilter.clauses, ...conditions]) {
pushAnd(expressions, condition);
}
return expressions; return expressions;
} }
@ -123,7 +137,7 @@ export async function getPagesPage(
input: { input: {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; scope?: ResearchScope;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
page: number; page: number;
@ -135,16 +149,18 @@ export async function getPagesPage(
}, },
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
): Promise<DomainPagesPageResult> { ): Promise<DomainPagesPageResult> {
const domain = normalizeDomainInput(input.domain, input.includeSubdomains); const target = parseResearchTargetOrThrow(input.domain, input.scope);
const scopeFilter = buildRelevantPagesScopeFilter(target);
const offset = (input.page - 1) * input.pageSize; const offset = (input.page - 1) * input.pageSize;
const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`]; const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`];
const filters = buildPageFilters(input.filters, input.search); const filters = buildPageFilters(input.filters, input.search, scopeFilter);
const cacheKey = await buildCacheKey("domain:pages-page", { const cacheKey = await buildCacheKey("domain:pages-page", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
projectId: input.projectId, projectId: input.projectId,
domain, domain: target.hostname,
includeSubdomains: input.includeSubdomains, scope: target.scope,
path: target.path,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
page: input.page, page: input.page,
@ -163,7 +179,7 @@ export async function getPagesPage(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const response = await dataforseo.domain.relevantPages({ const response = await dataforseo.domain.relevantPages({
target: domain, target: target.hostname,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
limit: input.pageSize, limit: input.pageSize,
@ -188,7 +204,7 @@ export async function getPagesPage(
); );
const result: DomainPagesPageResult = { const result: DomainPagesPageResult = {
domain, domain: target.hostname,
page: input.page, page: input.page,
pageSize: input.pageSize, pageSize: input.pageSize,
totalCount, totalCount,

View File

@ -144,7 +144,7 @@ function coreSiteTools(ctx: ToolContext): ToolSet {
{ {
projectId: project.id, projectId: project.id,
domain: project.domain, domain: project.domain,
includeSubdomains: false, scope: "domain",
locationCode: project.locationCode, locationCode: project.locationCode,
languageCode: project.languageCode, languageCode: project.languageCode,
}, },

View File

@ -36,7 +36,7 @@ export function marketTools(ctx: ToolContext): ToolSet {
{ {
projectId: project.id, projectId: project.id,
domain, domain,
includeSubdomains: false, scope: "domain",
locationCode: project.locationCode, locationCode: project.locationCode,
languageCode: project.languageCode, languageCode: project.languageCode,
}, },
@ -216,7 +216,7 @@ export function marketTools(ctx: ToolContext): ToolSet {
execute: async ({ domain }) => { execute: async ({ domain }) => {
try { try {
const { overview } = await BacklinksService.profileOverview( const { overview } = await BacklinksService.profileOverview(
{ target: domain, scope: "domain" }, { target: domain, scope: "subdomains" },
billingCustomer, billingCustomer,
"onboarding", "onboarding",
); );

View File

@ -29,56 +29,96 @@ const billed = {
result_count: 0, result_count: 0,
}; };
describe("normalizeBacklinksTarget", () => { function okResponse(result: unknown[]) {
it("treats explicit homepage URLs as page lookups", () => { return new Response(
expect(normalizeBacklinksTarget("https://Example.com/")).toEqual({ JSON.stringify({
apiTarget: "https://example.com/", status_code: 20000,
displayTarget: "https://example.com/", status_message: "Ok.",
scope: "page", tasks: [{ status_code: 20000, status_message: "Ok.", ...billed, result }],
}); }),
}); { status: 200, headers: { "Content-Type": "application/json" } },
);
}
it("trims trailing slashes from non-root page URLs", () => { describe("normalizeBacklinksTarget", () => {
it("defaults inputs with a path to a subfolder lookup", () => {
expect( expect(
normalizeBacklinksTarget("https://github.com/every-app/open-seo/"), normalizeBacklinksTarget("https://github.com/every-app/open-seo/"),
).toEqual({ ).toEqual({
apiTarget: "https://github.com/every-app/open-seo", apiTarget: "github.com",
displayTarget: "https://github.com/every-app/open-seo", displayTarget: "github.com/every-app/open-seo",
scope: "page", scope: "subfolder",
includeSubdomains: false,
path: "/every-app/open-seo",
}); });
}); });
it("treats bare hostnames as domain lookups", () => { it("strips query strings and fragments for subfolder lookups", () => {
expect(
normalizeBacklinksTarget("example.com/blog?utm_source=x#hero", {
scope: "subfolder",
}).path,
).toBe("/blog");
});
it("rejects subfolder scope without a path", () => {
expectValidationError(() =>
normalizeBacklinksTarget("example.com", { scope: "subfolder" }),
);
});
it("defaults bare hostnames to subdomains scope", () => {
expect(normalizeBacklinksTarget("Example.com")).toEqual({ expect(normalizeBacklinksTarget("Example.com")).toEqual({
apiTarget: "example.com", apiTarget: "example.com",
displayTarget: "example.com", displayTarget: "example.com",
scope: "domain", scope: "subdomains",
includeSubdomains: true,
path: "",
}); });
}); });
it("lets callers force domain scope for full URLs", () => { it("includes subdomains only for subdomains scope", () => {
expect( expect(
normalizeBacklinksTarget("https://Example.com/pricing", { normalizeBacklinksTarget("https://Example.com/pricing", {
scope: "domain", scope: "subdomains",
}), }),
).toEqual({ ).toEqual({
apiTarget: "example.com", apiTarget: "example.com",
displayTarget: "example.com", displayTarget: "example.com",
scope: "domain", scope: "subdomains",
includeSubdomains: true,
path: "",
}); });
}); });
it("lets callers force page scope for bare hostnames", () => { it("lets callers force a page lookup for bare hostnames", () => {
expect(
normalizeBacklinksTarget("Example.com", { scope: "exact_url" }),
).toEqual({
apiTarget: "https://example.com/",
displayTarget: "https://example.com/",
scope: "exact_url",
includeSubdomains: true,
path: "",
});
});
it("maps the legacy page scope onto exact_url", () => {
expect(normalizeBacklinksTarget("Example.com", { scope: "page" })).toEqual({ expect(normalizeBacklinksTarget("Example.com", { scope: "page" })).toEqual({
apiTarget: "https://example.com/", apiTarget: "https://example.com/",
displayTarget: "https://example.com/", displayTarget: "https://example.com/",
scope: "page", scope: "exact_url",
includeSubdomains: true,
path: "",
}); });
}); });
it("rejects page targets with query strings or fragments", () => { it("rejects exact-url targets with query strings or fragments", () => {
expectValidationError(() => expectValidationError(() =>
normalizeBacklinksTarget("https://example.com/pricing?token=secret#hero"), normalizeBacklinksTarget(
"https://example.com/pricing?token=secret#hero",
{ scope: "exact_url" },
),
); );
}); });
@ -136,23 +176,7 @@ describe("fetchBacklinksSummary", () => {
}); });
it("treats null summary results as a valid zero-data response", async () => { it("treats null summary results as a valid zero-data response", async () => {
vi.mocked(fetch).mockResolvedValue( vi.mocked(fetch).mockResolvedValue(okResponse([null]));
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
...billed,
result: [null],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
classifyBacklinksError.mockReturnValue(null); classifyBacklinksError.mockReturnValue(null);
await expect( await expect(
@ -161,23 +185,7 @@ describe("fetchBacklinksSummary", () => {
}); });
it("treats empty summary results as a valid zero-data response", async () => { it("treats empty summary results as a valid zero-data response", async () => {
vi.mocked(fetch).mockResolvedValue( vi.mocked(fetch).mockResolvedValue(okResponse([]));
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
...billed,
result: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
classifyBacklinksError.mockReturnValue(null); classifyBacklinksError.mockReturnValue(null);
await expect( await expect(
@ -185,26 +193,28 @@ describe("fetchBacklinksSummary", () => {
).resolves.toMatchObject({ data: {} }); ).resolves.toMatchObject({ data: {} });
}); });
it("asks DataForSEO to exclude subdomains for a domain-scoped target", async () => {
vi.mocked(fetch).mockResolvedValue(okResponse([]));
classifyBacklinksError.mockReturnValue(null);
await fetchBacklinksSummary({
target: "example.com",
includeSubdomains: false,
});
const body = vi.mocked(fetch).mock.calls[0]?.[1]?.body;
if (typeof body !== "string") {
throw new Error("Expected DataForSEO request body to be a string");
}
expect(JSON.parse(body)).toMatchObject([
{ target: "example.com", include_subdomains: false },
]);
});
it("treats empty backlinks rows and history results as valid empty arrays", async () => { it("treats empty backlinks rows and history results as valid empty arrays", async () => {
const emptyOk = () =>
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
...billed,
result: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
vi.mocked(fetch) vi.mocked(fetch)
.mockResolvedValueOnce(emptyOk()) .mockResolvedValueOnce(okResponse([]))
.mockResolvedValueOnce(emptyOk()); .mockResolvedValueOnce(okResponse([]));
classifyBacklinksError.mockReturnValue(null); classifyBacklinksError.mockReturnValue(null);
await expect( await expect(

View File

@ -21,7 +21,15 @@ import {
type DataforseoApiResponse, type DataforseoApiResponse,
} from "@/server/lib/dataforseo/envelope"; } from "@/server/lib/dataforseo/envelope";
type BacklinksRequest = { target: string }; type BacklinksRequest = {
target: string;
/**
* Whether the target's subdomains count. Defaults to DataForSEO's `true`.
* The API ignores it for page targets; `backlinks/history/live` has no such
* field, so a domain-scoped history is always subdomain-inclusive.
*/
includeSubdomains?: boolean;
};
type BacklinksListRequest = BacklinksRequest & type BacklinksListRequest = BacklinksRequest &
BacklinksSpamFilterOptions & { BacklinksSpamFilterOptions & {
limit?: number; limit?: number;
@ -140,7 +148,7 @@ export const backlinksHistoryItemSchema = z
function buildCommonPayload(input: BacklinksRequest) { function buildCommonPayload(input: BacklinksRequest) {
return { return {
target: input.target, target: input.target,
include_subdomains: true, include_subdomains: input.includeSubdomains ?? true,
include_indirect_links: true, include_indirect_links: true,
exclude_internal_backlinks: true, exclude_internal_backlinks: true,
backlinks_status_type: "live", backlinks_status_type: "live",

View File

@ -211,8 +211,10 @@ export async function fetchRankedKeywords(input: {
orderBy?: string[]; orderBy?: string[];
filters?: unknown[]; filters?: unknown[];
itemTypes?: DataforseoLabsItemType[]; itemTypes?: DataforseoLabsItemType[];
includeSubdomains?: boolean;
}): Promise<DataforseoApiResponse<RankedKeywordsPage>> { }): Promise<DataforseoApiResponse<RankedKeywordsPage>> {
// Note: ranked_keywords has no include_subdomains parameter — a domain
// target always covers the hostname plus its subdomains. Narrower scopes
// are expressed through `filters` (see researchScopeFilters.ts).
const response = await labsApi().googleRankedKeywordsLive([ const response = await labsApi().googleRankedKeywordsLive([
new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({ new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
target: input.target, target: input.target,
@ -223,7 +225,6 @@ export async function fetchRankedKeywords(input: {
order_by: input.orderBy, order_by: input.orderBy,
filters: input.filters, filters: input.filters,
item_types: input.itemTypes, item_types: input.itemTypes,
include_subdomains: input.includeSubdomains,
}), }),
]); ]);
const task = assertOk(response); const task = assertOk(response);

View File

@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
BACKLINKS_SUBFOLDER_FILTER_CONDITIONS,
parseResearchTarget,
RESEARCH_SCOPES,
RESEARCH_SCOPE_FILTER_SLOTS,
} from "@/shared/researchScope";
import {
buildBacklinksScopeFilter,
buildRankedKeywordsScopeFilter,
buildRelevantPagesScopeFilter,
} from "./researchScopeFilters";
function target(
input: string,
scope: Parameters<typeof parseResearchTarget>[1],
) {
const parsed = parseResearchTarget(input, scope);
if (!parsed.ok) throw new Error(parsed.message);
return parsed.target;
}
describe("buildRankedKeywordsScopeFilter", () => {
it("adds nothing for subdomains scope", () => {
expect(
buildRankedKeywordsScopeFilter(target("example.com", "subdomains")),
).toEqual({ clauses: [], conditionCount: 0 });
});
it("pins the exact hostname (plus www) for domain scope", () => {
const filter = buildRankedKeywordsScopeFilter(
target("example.com", "domain"),
);
expect(filter.clauses).toEqual([
[
"ranked_serp_element.serp_item.domain",
"in",
["example.com", "www.example.com"],
],
]);
expect(filter.conditionCount).toBe(1);
});
it("matches the subfolder, its children, and query variants — not siblings", () => {
const filter = buildRankedKeywordsScopeFilter(
target("example.com/blog", "subfolder"),
);
expect(filter.clauses[1]).toEqual([
["ranked_serp_element.serp_item.relative_url", "=", "/blog"],
"or",
["ranked_serp_element.serp_item.relative_url", "like", "/blog/%"],
"or",
["ranked_serp_element.serp_item.relative_url", "like", "/blog?%"],
]);
expect(filter.conditionCount).toBe(4);
});
it("escapes like wildcards in the path but not equality values", () => {
const filter = buildRankedKeywordsScopeFilter(
target("example.com/100%25_deals", "subfolder"),
);
expect(filter.clauses[1]).toEqual([
["ranked_serp_element.serp_item.relative_url", "=", "/100%25_deals"],
"or",
[
"ranked_serp_element.serp_item.relative_url",
"like",
"/100\\%25\\_deals/%",
],
"or",
[
"ranked_serp_element.serp_item.relative_url",
"like",
"/100\\%25\\_deals?%",
],
]);
});
it("covers trailing-slash and query variants for exact_url scope", () => {
const filter = buildRankedKeywordsScopeFilter(
target("example.com/pricing", "exact_url"),
);
expect(filter.clauses[1]).toEqual([
[
"ranked_serp_element.serp_item.relative_url",
"in",
["/pricing", "/pricing/"],
],
"or",
["ranked_serp_element.serp_item.relative_url", "like", "/pricing?%"],
"or",
["ranked_serp_element.serp_item.relative_url", "like", "/pricing/?%"],
]);
});
});
describe("buildRelevantPagesScopeFilter", () => {
it("pins both host variants for domain scope", () => {
const filter = buildRelevantPagesScopeFilter(
target("example.com", "domain"),
);
expect(filter.clauses).toEqual([
[
["page_address", "like", "%://example.com/%"],
"or",
["page_address", "like", "%://www.example.com/%"],
],
]);
expect(filter.conditionCount).toBe(2);
});
it("builds absolute prefix patterns for subfolder scope", () => {
const filter = buildRelevantPagesScopeFilter(
target("example.com/blog", "subfolder"),
);
expect(filter.clauses[0]).toEqual([
["page_address", "like", "%://example.com/blog"],
"or",
["page_address", "like", "%://example.com/blog/%"],
"or",
["page_address", "like", "%://www.example.com/blog"],
"or",
["page_address", "like", "%://www.example.com/blog/%"],
]);
expect(filter.conditionCount).toBe(4);
});
});
it("keeps the shared filter-slot constants in sync with the built clauses", () => {
for (const scope of RESEARCH_SCOPES) {
const parsed = target("example.com/blog", scope);
expect(buildRankedKeywordsScopeFilter(parsed).conditionCount).toBe(
RESEARCH_SCOPE_FILTER_SLOTS.keywords[parsed.scope],
);
expect(buildRelevantPagesScopeFilter(parsed).conditionCount).toBe(
RESEARCH_SCOPE_FILTER_SLOTS.pages[parsed.scope],
);
}
expect(
buildBacklinksScopeFilter("url_to", {
scope: "subfolder",
apiTarget: "example.com",
path: "/blog",
}).conditionCount,
).toBe(BACKLINKS_SUBFOLDER_FILTER_CONDITIONS);
});

View File

@ -0,0 +1,173 @@
import {
escapeLikeTerm,
joinClauses,
type FilterClause,
} from "@/server/lib/dataforseo/filters";
import type { ResearchScope, ResearchTarget } from "@/shared/researchScope";
/**
* Provider-side scope filters for the DataForSEO Labs endpoints. A Labs domain
* target rolls up the hostname AND all of its subdomains (verified against the
* live API), so every scope narrower than `subdomains` needs filter conditions:
*
* - ranked_keywords rows expose `serp_item.domain` (exact result hostname,
* which reports `www.` for www-canonical sites) and `serp_item.relative_url`
* (path + query string).
* - relevant_pages rows only expose `page_address` (absolute URL), so both the
* host pin and the path prefix ride on `like` patterns. `regex` is unusable:
* the API rejects any pattern containing `/`.
*
* Query-string URL variants (`/path?x=y`) are covered for ranked_keywords but
* not for relevant_pages, where query-string page addresses are rare and each
* extra pattern costs one of the 8 filter conditions.
*/
export type ScopeFilter = {
/** Clauses to AND in front of user filters. Empty for subdomains scope. */
clauses: FilterClause[];
/** How many of the 8-condition budget the clauses consume. */
conditionCount: number;
};
const NO_FILTER: ScopeFilter = { clauses: [], conditionCount: 0 };
/**
* Leaf conditions in a clause list or joined expression, counting through
* nested OR/AND groups and skipping "and"/"or" separators.
* RESEARCH_SCOPE_FILTER_SLOTS mirrors these counts for the client; a test
* pins the two together.
*/
export function countExpressionConditions(clauses: readonly unknown[]): number {
let count = 0;
for (const clause of clauses) {
if (!Array.isArray(clause)) continue;
count += Array.isArray(clause[0]) ? countExpressionConditions(clause) : 1;
}
return count;
}
function scopeFilter(clauses: FilterClause[]): ScopeFilter {
return { clauses, conditionCount: countExpressionConditions(clauses) };
}
function subfolderUrlClauses(
field: string,
hostname: string,
path: string,
): FilterClause {
const hosts = [hostname, `www.${hostname}`].map(escapeLikeTerm);
const escapedPath = escapeLikeTerm(path);
return joinClauses(
hosts.flatMap((host) => [
[field, "like", `%://${host}${escapedPath}`],
[field, "like", `%://${host}${escapedPath}/%`],
]),
"or",
);
}
/**
* Scope filter on an absolute-URL field of the Backlinks API (`url_to` for
* backlink rows, `url` for domain pages). Only subfolder scope needs filters
* the API has no prefix targeting every other scope rides on the target.
*/
export function buildBacklinksScopeFilter(
field: "url_to" | "url",
target: { scope: ResearchScope; apiTarget: string; path: string },
): ScopeFilter {
if (target.scope !== "subfolder") return NO_FILTER;
// For subfolder scope apiTarget is the bare hostname.
return scopeFilter([
subfolderUrlClauses(field, target.apiTarget, target.path),
]);
}
/** ANDs scope clauses in front of an already-joined flat filter expression. */
export function prependScopeClauses(
scope: ScopeFilter,
expression: unknown[],
): unknown[] {
if (scope.clauses.length === 0) return expression;
const joined = joinClauses(scope.clauses, "and");
return expression.length === 0 ? joined : [...joined, "and", ...expression];
}
function hostPin(target: ResearchTarget): FilterClause {
return [
"ranked_serp_element.serp_item.domain",
"in",
[target.hostname, `www.${target.hostname}`],
];
}
export function buildRankedKeywordsScopeFilter(
target: ResearchTarget,
): ScopeFilter {
const relativeUrl = "ranked_serp_element.serp_item.relative_url";
const path = target.path || "/";
const escapedPath = escapeLikeTerm(path);
switch (target.scope) {
case "subdomains":
return NO_FILTER;
case "domain":
return scopeFilter([hostPin(target)]);
case "subfolder":
return scopeFilter([
hostPin(target),
joinClauses(
[
[relativeUrl, "=", path],
[relativeUrl, "like", `${escapedPath}/%`],
[relativeUrl, "like", `${escapedPath}?%`],
],
"or",
),
]);
case "exact_url":
return scopeFilter([
hostPin(target),
joinClauses(
[
[relativeUrl, "in", [path, `${path}/`]],
[relativeUrl, "like", `${escapedPath}?%`],
[relativeUrl, "like", `${escapedPath}/?%`],
],
"or",
),
]);
}
}
export function buildRelevantPagesScopeFilter(
target: ResearchTarget,
): ScopeFilter {
const hosts = [target.hostname, `www.${target.hostname}`].map(escapeLikeTerm);
const path = escapeLikeTerm(target.path || "/");
switch (target.scope) {
case "subdomains":
return NO_FILTER;
case "domain":
return scopeFilter([
joinClauses(
hosts.map((host) => ["page_address", "like", `%://${host}/%`]),
"or",
),
]);
case "subfolder":
return scopeFilter([
subfolderUrlClauses("page_address", target.hostname, target.path),
]);
case "exact_url":
return scopeFilter([
joinClauses(
hosts.flatMap((host) => [
["page_address", "like", `%://${host}${path}`],
["page_address", "like", `%://${host}${path}/`],
]),
"or",
),
]);
}
}

View File

@ -31,11 +31,18 @@ export type LlmTarget =
export function buildLlmTarget(input: { export function buildLlmTarget(input: {
type: "domain" | "keyword"; type: "domain" | "keyword";
value: string; value: string;
/**
* Domain targets only. Defaults to the historical behavior (subdomains
* included); research scopes narrower than `subdomains` pass `false`. There
* is no URL/path-level targeting in this API page-level scoping happens by
* post-filtering the returned page URLs.
*/
includeSubdomains?: boolean;
}): LlmTarget { }): LlmTarget {
if (input.type === "domain") { if (input.type === "domain") {
return { return {
domain: input.value, domain: input.value,
include_subdomains: true, include_subdomains: input.includeSubdomains ?? true,
search_filter: "include", search_filter: "include",
search_scope: ["any"], search_scope: ["any"],
}; };

View File

@ -1,118 +1,79 @@
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; import {
import { parse as parseTld } from "tldts"; resolveBacklinksScope,
type BacklinksScopeWithLegacy,
} from "@/types/schemas/backlinks";
import {
parseResearchTarget,
type ResearchScope,
} from "@/shared/researchScope";
type NormalizedBacklinkTarget = { type NormalizedBacklinkTarget = {
apiTarget: string; apiTarget: string;
displayTarget: string; displayTarget: string;
scope: "domain" | "page"; scope: ResearchScope;
/** DataForSEO `include_subdomains`; ignored by the API for page targets. */
includeSubdomains: boolean;
/**
* Subfolder scope only: the normalized path driving the url_to/url prefix
* filters (the API itself has no prefix targeting). `""` for other scopes.
*/
path: string;
}; };
type NormalizeBacklinksTargetOptions = { type NormalizeBacklinksTargetOptions = {
scope?: BacklinksLookupInput["scope"]; scope?: BacklinksScopeWithLegacy;
}; };
function normalizePageTargetUrl(url: URL, hostname: string): string { /**
const normalizedUrl = new URL(url.toString()); * Backlinks-flavored wrapper over the shared research-target parser. The
normalizedUrl.hostname = hostname; * backlinks-specific rules: an exact-URL target is sent as an absolute URL
* (preserving an explicit http:// scheme, since url matching is exact) and
if (normalizedUrl.pathname.length > 1) { * rejects query strings/fragments instead of silently stripping them.
normalizedUrl.pathname = normalizedUrl.pathname.replace(/\/+$/, ""); */
}
return normalizedUrl.toString();
}
export function normalizeBacklinksTarget( export function normalizeBacklinksTarget(
input: string, input: string,
options: NormalizeBacklinksTargetOptions = {}, options: NormalizeBacklinksTargetOptions = {},
): NormalizedBacklinkTarget { ): NormalizedBacklinkTarget {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) { const requestedScope = options.scope
throw new AppError("VALIDATION_ERROR", "Target is required"); ? resolveBacklinksScope(options.scope)
: undefined;
const parsed = parseResearchTarget(trimmed, requestedScope);
if (!parsed.ok) {
throw new AppError("VALIDATION_ERROR", parsed.message);
} }
const target = parsed.target;
const hasExplicitProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed); if (target.scope !== "exact_url") {
const withProtocol = hasExplicitProtocol ? trimmed : `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new AppError("VALIDATION_ERROR", "Target is invalid");
}
const exactHostname = parsed.hostname.toLowerCase();
const domainHostname = exactHostname.replace(/^www\./, "");
if (!domainHostname || !domainHostname.includes(".")) {
throw new AppError("VALIDATION_ERROR", "Target is invalid");
}
const parsedHostname = parseTld(domainHostname, {
allowPrivateDomains: true,
});
if (
parsedHostname.isIp ||
!parsedHostname.publicSuffix ||
(parsedHostname.isIcann !== true && parsedHostname.isPrivate !== true)
) {
throw new AppError("VALIDATION_ERROR", "Target is invalid");
}
if (parsed.username || parsed.password) {
throw new AppError(
"VALIDATION_ERROR",
"Page URLs with embedded credentials are not supported",
);
}
const hasMeaningfulPath = parsed.pathname !== "/";
const requestedScope = options.scope;
if (requestedScope === "domain") {
return { return {
apiTarget: domainHostname, apiTarget: target.hostname,
displayTarget: domainHostname, displayTarget:
scope: "domain", target.scope === "subfolder" ? target.display : target.hostname,
scope: target.scope,
includeSubdomains: target.scope === "subdomains",
path: target.scope === "subfolder" ? target.path : "",
}; };
} }
if (parsed.search || parsed.hash) { // Query strings and fragments would target a different page than the one
// the user sees, so a page lookup rejects them rather than dropping them.
if (/[?#]/.test(trimmed)) {
throw new AppError( throw new AppError(
"VALIDATION_ERROR", "VALIDATION_ERROR",
"Page URLs with query strings or fragments are not supported", "Page URLs with query strings or fragments are not supported",
); );
} }
if (requestedScope === "page") { const protocol = /^http:\/\//i.test(trimmed) ? "http" : "https";
const normalizedUrl = new URL(parsed.toString()); const pageUrl = `${protocol}://${target.urlHostname}${target.path || "/"}`;
if (!hasExplicitProtocol && !hasMeaningfulPath) {
normalizedUrl.pathname = "/";
}
const normalizedTarget = normalizePageTargetUrl(
normalizedUrl,
exactHostname,
);
return { return {
apiTarget: normalizedTarget, apiTarget: pageUrl,
displayTarget: normalizedTarget, displayTarget: pageUrl,
scope: "page", scope: "exact_url",
}; // Irrelevant for a page target; kept true so the payload is unchanged.
} includeSubdomains: true,
path: "",
if (hasExplicitProtocol || hasMeaningfulPath) {
const normalizedTarget = normalizePageTargetUrl(parsed, exactHostname);
return {
apiTarget: normalizedTarget,
displayTarget: normalizedTarget,
scope: "page",
};
}
return {
apiTarget: domainHostname,
displayTarget: domainHostname,
scope: "domain",
}; };
} }

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeDomainInput } from "@/server/lib/domainUtils"; import { normalizeDomainInput } from "@/server/lib/domainUtils";
import { isValidDomainHost } from "@/types/schemas/domain"; import { isValidDomainHost } from "@/shared/researchScope";
describe("isValidDomainHost", () => { describe("isValidDomainHost", () => {
it("accepts real registrable domains", () => { it("accepts real registrable domains", () => {

View File

@ -1,6 +1,22 @@
import { getDomain } from "tldts"; import { getDomain } from "tldts";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { isValidDomainHost } from "@/types/schemas/domain"; import {
isValidDomainHost,
parseResearchTarget,
type ResearchScope,
type ResearchTarget,
} from "@/shared/researchScope";
export function parseResearchTargetOrThrow(
input: string,
scope?: ResearchScope,
): ResearchTarget {
const parsed = parseResearchTarget(input, scope);
if (!parsed.ok) {
throw new AppError("VALIDATION_ERROR", parsed.message);
}
return parsed.target;
}
export function toRelativePath(url: string | null | undefined): string | null { export function toRelativePath(url: string | null | undefined): string | null {
if (!url) return null; if (!url) return null;

View File

@ -184,33 +184,6 @@ describe("DataForSEO research MCP tools", () => {
expect(textContent(result)).toContain("Do you serve breakfast?"); expect(textContent(result)).toContain("Do you serve breakfast?");
}); });
it("passes only explicit brand exclusions to ranked keyword filters", async () => {
const rankedKeywords = vi.fn().mockResolvedValue({
items: [],
totalCount: 0,
});
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
const { getRankedKeywordsTool } = researchTools;
await getRankedKeywordsTool.handler(
{
projectId: "project_1",
target: "acmeexample.com",
excludeBrandTerms: ["acme"],
},
toolContext,
);
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({
filters: [["keyword_data.keyword", "not_ilike", "%acme%"]],
}),
);
});
it("filters SERP competitors only by explicit excluded domains", async () => { it("filters SERP competitors only by explicit excluded domains", async () => {
const serpCompetitors = vi.fn().mockResolvedValue([ const serpCompetitors = vi.fn().mockResolvedValue([
{ domain: "directory.example", visibility: 10 }, { domain: "directory.example", visibility: 10 },
@ -382,3 +355,67 @@ describe("DataForSEO research MCP tools", () => {
expect(rows[0]).not.toHaveProperty("monthly_searches"); expect(rows[0]).not.toHaveProperty("monthly_searches");
}); });
}); });
describe("get_ranked_keywords scope handling", () => {
const rankedKeywords =
vi.fn<
(input: {
filters?: unknown[];
}) => Promise<{ items: unknown[]; totalCount: number }>
>();
beforeEach(() => {
mocks.getProjectForOrganization.mockResolvedValue(usProjectRow);
rankedKeywords.mockResolvedValue({ items: [], totalCount: 0 });
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
});
it("passes only explicit brand exclusions to ranked keyword filters", async () => {
await researchTools.getRankedKeywordsTool.handler(
{
projectId: "project_1",
target: "acmeexample.com",
scope: "subdomains",
excludeBrandTerms: ["acme"],
},
toolContext,
);
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({
filters: [["keyword_data.keyword", "not_ilike", "%acme%"]],
}),
);
});
it("defaults a bare domain to subdomains scope with no scope filters", async () => {
const result = await researchTools.getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com" },
toolContext,
);
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({
target: "acmeexample.com",
filters: undefined,
}),
);
expect(result.structuredContent).toMatchObject({
target: "acmeexample.com",
scope: "subdomains",
});
});
// The clause shape itself is pinned in researchScopeFilters.test.ts; here
// only "the scope filter reaches the API call" is the invariant.
it("sends scope filters for an explicit narrower scope", async () => {
await researchTools.getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com", scope: "domain" },
toolContext,
);
expect(Array.isArray(rankedKeywords.mock.calls[0]?.[0].filters)).toBe(true);
});
});

View File

@ -28,6 +28,16 @@ import {
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
} from "@/server/mcp/schemas"; } from "@/server/mcp/schemas";
import { assertFilterConditionBudget } from "@/server/lib/dataforseo/filters";
import {
buildRankedKeywordsScopeFilter,
type ScopeFilter,
} from "@/server/lib/dataforseo/researchScopeFilters";
import { parseResearchTargetOrThrow } from "@/server/lib/domainUtils";
import {
RESEARCH_SCOPE_PARAM_DESCRIPTION,
researchScopeSchema,
} from "@/shared/researchScope";
const rankedResultTypeSchema = z.enum([ const rankedResultTypeSchema = z.enum([
"organic", "organic",
@ -128,6 +138,9 @@ const getRankedKeywordsInputSchema = {
target: rankedTargetSchema.describe( target: rankedTargetSchema.describe(
"Domain (no protocol/www) or absolute page URL to list ranked keywords for.", "Domain (no protocol/www) or absolute page URL to list ranked keywords for.",
), ),
scope: researchScopeSchema
.optional()
.describe(RESEARCH_SCOPE_PARAM_DESCRIPTION),
market: marketSchema, market: marketSchema,
locationCode: locationCodeSchema locationCode: locationCodeSchema
.optional() .optional()
@ -148,9 +161,7 @@ const getRankedKeywordsInputSchema = {
includeSubdomains: z includeSubdomains: z
.boolean() .boolean()
.optional() .optional()
.describe( .describe("Deprecated: use scope ('subdomains' or 'domain') instead."),
"Include subdomains of the target. Defaults to true for domains, false for page URLs.",
),
minSearchVolume: z minSearchVolume: z
.number() .number()
.int() .int()
@ -446,18 +457,27 @@ function pushAnd(filters: unknown[], condition: unknown[]) {
filters.push(condition); filters.push(condition);
} }
function buildRankedKeywordFilters(args: { function buildRankedKeywordFilters(
args: {
minSearchVolume?: number; minSearchVolume?: number;
maxRank?: number; maxRank?: number;
excludeBrandTerms?: string[]; excludeBrandTerms?: string[];
}) { },
scopeFilter?: ScopeFilter,
) {
const filters: unknown[] = []; const filters: unknown[] = [];
let conditionCount = 0;
if (scopeFilter) {
for (const clause of scopeFilter.clauses) pushAnd(filters, clause);
conditionCount += scopeFilter.conditionCount;
}
if (args.minSearchVolume != null) { if (args.minSearchVolume != null) {
pushAnd(filters, [ pushAnd(filters, [
"keyword_data.keyword_info.search_volume", "keyword_data.keyword_info.search_volume",
">=", ">=",
args.minSearchVolume, args.minSearchVolume,
]); ]);
conditionCount += 1;
} }
if (args.maxRank != null) { if (args.maxRank != null) {
pushAnd(filters, [ pushAnd(filters, [
@ -465,12 +485,15 @@ function buildRankedKeywordFilters(args: {
"<=", "<=",
args.maxRank, args.maxRank,
]); ]);
conditionCount += 1;
} }
if (args.excludeBrandTerms != null) { if (args.excludeBrandTerms != null) {
for (const term of args.excludeBrandTerms) { for (const term of args.excludeBrandTerms) {
pushAnd(filters, ["keyword_data.keyword", "not_ilike", `%${term}%`]); pushAnd(filters, ["keyword_data.keyword", "not_ilike", `%${term}%`]);
} }
conditionCount += args.excludeBrandTerms.length;
} }
assertFilterConditionBudget(conditionCount);
return filters.length > 0 ? filters : undefined; return filters.length > 0 ? filters : undefined;
} }
@ -638,6 +661,8 @@ export const getRankedKeywordsTool = {
outputSchema: { outputSchema: {
keywords: z.array(looseObjectOutputSchema), keywords: z.array(looseObjectOutputSchema),
totalCount: z.number().nullable(), totalCount: z.number().nullable(),
target: z.string().optional(),
scope: researchScopeSchema.optional(),
...optionalMetaOutputSchema, ...optionalMetaOutputSchema,
}, },
annotations: { annotations: {
@ -648,39 +673,61 @@ export const getRankedKeywordsTool = {
}, },
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => { handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const targetIsPage = /^https?:\/\//.test(args.target); // Legacy includeSubdomains only ever selected between whole-host scopes
// for bare domains; for page URLs it meant exact-page results. Mapping it
// to domain/subdomains for a URL target would silently drop the path.
const parsedDefault = parseResearchTargetOrThrow(args.target);
const legacyScope =
args.includeSubdomains == null
? undefined
: parsedDefault.path === ""
? args.includeSubdomains
? "subdomains"
: "domain"
: "exact_url";
const requestedScope = args.scope ?? legacyScope;
const target = requestedScope
? parseResearchTargetOrThrow(args.target, requestedScope)
: parsedDefault;
const scopeFilter = buildRankedKeywordsScopeFilter(target);
const market = resolveMarketSelector(args, context.project); const market = resolveMarketSelector(args, context.project);
const keywords = await client.domain.rankedKeywords({ const keywords = await client.domain.rankedKeywords({
target: args.target, target: target.hostname,
locationCode: market.locationCode, locationCode: market.locationCode,
languageCode: market.languageCode, languageCode: market.languageCode,
limit: args.limit ?? 50, limit: args.limit ?? 50,
offset: args.offset, offset: args.offset,
orderBy: sortOrderByRankedMode(args.sortBy), orderBy: sortOrderByRankedMode(args.sortBy),
filters: buildRankedKeywordFilters({ filters: buildRankedKeywordFilters(
{
minSearchVolume: args.minSearchVolume, minSearchVolume: args.minSearchVolume,
maxRank: args.maxRank, maxRank: args.maxRank,
excludeBrandTerms: args.excludeBrandTerms, excludeBrandTerms: args.excludeBrandTerms,
}), },
scopeFilter,
),
itemTypes: args.resultTypes, itemTypes: args.resultTypes,
includeSubdomains: args.includeSubdomains ?? !targetIsPage,
}); });
const rankedRows = keywords.items.map(toRankedKeywordRow); const rankedRows = keywords.items.map(toRankedKeywordRow);
const targetLabel = `${target.display} (scope: ${target.scope})`;
const text = const text =
rankedRows.length === 0 rankedRows.length === 0
? `No ranked keyword rows for ${args.target}.` ? `No ranked keyword rows for ${targetLabel}.`
: `Found ${rankedRows.length} ranked keyword rows for ${args.target}${keywords.totalCount != null ? ` (of ${keywords.totalCount} total)` : ""}:\n${formatMcpTable(rankedRows, RANKED_KEYWORD_COLUMNS)}`; : `Found ${rankedRows.length} ranked keyword rows for ${targetLabel}${keywords.totalCount != null ? ` (of ${keywords.totalCount} total)` : ""}:\n${formatMcpTable(rankedRows, RANKED_KEYWORD_COLUMNS)}`;
return mcpResponse({ return mcpResponse({
text, text,
meta: buildProjectMeta( meta: buildProjectMeta(
context, context,
args.projectId, args.projectId,
`/p/${args.projectId}/domain`, `/p/${args.projectId}/domain`,
{ domain: target.display, scope: target.scope },
), ),
structuredContent: { structuredContent: {
keywords: keywords.items, keywords: keywords.items,
totalCount: keywords.totalCount, totalCount: keywords.totalCount,
target: target.display,
scope: target.scope,
}, },
}); });
}), }),

View File

@ -13,6 +13,13 @@ import {
type McpTableColumn, type McpTableColumn,
} from "@/server/mcp/table"; } from "@/server/mcp/table";
import { projectIdSchema } from "@/server/mcp/schemas"; import { projectIdSchema } from "@/server/mcp/schemas";
import {
BACKLINKS_SCOPE_DESCRIPTION,
backlinksScopeWithLegacySchema,
resolveBacklinksScope,
} from "@/types/schemas/backlinks";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseo";
import { researchScopeSchema } from "@/shared/researchScope";
const REFERRING_DOMAIN_COLUMNS: McpTableColumn<unknown>[] = [ const REFERRING_DOMAIN_COLUMNS: McpTableColumn<unknown>[] = [
{ header: "domain", value: (row) => readPath(row, "domain") }, { header: "domain", value: (row) => readPath(row, "domain") },
@ -32,12 +39,9 @@ const inputSchema = {
.describe( .describe(
"Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').", "Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').",
), ),
scope: z scope: backlinksScopeWithLegacySchema
.enum(["domain", "page"])
.optional() .optional()
.describe( .describe(BACKLINKS_SCOPE_DESCRIPTION),
"'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.",
),
hideSpam: z hideSpam: z
.boolean() .boolean()
.optional() .optional()
@ -55,11 +59,14 @@ export const getBacklinksOverviewTool = {
config: { config: {
title: "Get backlinks overview", title: "Get backlinks overview",
description: description:
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~50 typical for a domain, ~25 for a single page). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.", "Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~50 typical for a domain, ~25 for a single page). Note: bare domains default to scope 'subdomains'; pass scope 'domain' to exclude subdomains from the totals. Targets with a path default to 'subfolder', whose counts come from filtered backlink totals (no rank/trends/referring-domain breakdown). Trend data always includes subdomains (provider limitation). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
inputSchema, inputSchema,
outputSchema: { outputSchema: {
target: z.string(),
scope: researchScopeSchema,
scopeNote: z.string().optional(),
overview: looseObjectOutputSchema, overview: looseObjectOutputSchema,
referringDomains: looseObjectOutputSchema, referringDomains: looseObjectOutputSchema.optional(),
...optionalMetaOutputSchema, ...optionalMetaOutputSchema,
}, },
annotations: { annotations: {
@ -69,11 +76,22 @@ export const getBacklinksOverviewTool = {
}, },
}, },
handler: withMcpProjectAuth(async (args: Args, context) => { handler: withMcpProjectAuth(async (args: Args, context) => {
const lookup = { target: args.target, scope: args.scope }; const lookup = {
target: args.target,
scope: args.scope ? resolveBacklinksScope(args.scope) : undefined,
};
const spamOptions = { hideSpam: args.hideSpam ?? true }; const spamOptions = { hideSpam: args.hideSpam ?? true };
// referring_domains has no URL filter, so the per-domain breakdown is
// skipped for subfolder scope (the referring-domain count in the summary
// is still subfolder-accurate).
const resolvedScope = normalizeBacklinksTarget(args.target, {
scope: lookup.scope,
}).scope;
const [overview, refDomains] = await Promise.all([ const [overview, refDomains] = await Promise.all([
BacklinksService.profileOverview(lookup, context.billing), BacklinksService.profileOverview(lookup, context.billing),
BacklinksService.profileReferringDomainsPage( resolvedScope === "subfolder"
? Promise.resolve(null)
: BacklinksService.profileReferringDomainsPage(
{ {
...lookup, ...lookup,
page: 1, page: 1,
@ -86,16 +104,28 @@ export const getBacklinksOverviewTool = {
spamOptions, spamOptions,
), ),
]); ]);
const topDomains = refDomains.rows ?? []; const topDomains = refDomains?.rows ?? [];
const summary = overview.overview.summary; const summary = overview.overview.summary;
const { displayTarget, scope } = overview.overview;
// backlinks/history has no include_subdomains, so trend series stay
// subdomain-inclusive even when the summary excludes subdomains.
const scopeNote =
scope === "domain"
? "Summary excludes subdomains; trend data includes subdomains (provider limitation)."
: scope === "subfolder"
? "Counts are computed from filtered backlink totals; rank, trends, and the referring-domains breakdown aren't available for subfolders."
: undefined;
const text = [ const text = [
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`, `Backlinks profile for ${displayTarget} (scope: ${scope}):`,
...(scopeNote ? [`Note: ${scopeNote}`] : []),
`- backlinks: ${formatMetric(summary.backlinks)}`, `- backlinks: ${formatMetric(summary.backlinks)}`,
`- referring domains: ${formatMetric(summary.referringDomains)}`, `- referring domains: ${formatMetric(summary.referringDomains)}`,
`- referring pages: ${formatMetric(summary.referringPages)}`, `- referring pages: ${formatMetric(summary.referringPages)}`,
`- rank: ${formatMetric(summary.rank)}`, `- rank: ${formatMetric(summary.rank)}`,
"", "",
topDomains.length === 0 refDomains === null
? "Referring-domains breakdown unavailable for subfolder scope."
: topDomains.length === 0
? "No referring domains found." ? "No referring domains found."
: `Referring domains (${topDomains.length}):\n${formatMcpTable(topDomains, REFERRING_DOMAIN_COLUMNS)}`, : `Referring domains (${topDomains.length}):\n${formatMcpTable(topDomains, REFERRING_DOMAIN_COLUMNS)}`,
].join("\n"); ].join("\n");
@ -105,9 +135,15 @@ export const getBacklinksOverviewTool = {
context, context,
args.projectId, args.projectId,
`/p/${args.projectId}/backlinks`, `/p/${args.projectId}/backlinks`,
{ target: args.target }, { target: args.target, scope },
), ),
structuredContent: { overview, referringDomains: refDomains }, structuredContent: {
target: displayTarget,
scope,
scopeNote,
overview,
referringDomains: refDomains ?? undefined,
},
}); });
}), }),
}; };

View File

@ -12,13 +12,17 @@ import { projectIdSchema } from "@/server/mcp/schemas";
import { import {
BACKLINKS_DEFAULT_SORT, BACKLINKS_DEFAULT_SORT,
BACKLINKS_PAGE_SIZES, BACKLINKS_PAGE_SIZES,
BACKLINKS_SCOPE_DESCRIPTION,
DEFAULT_BACKLINKS_PAGE_SIZE, DEFAULT_BACKLINKS_PAGE_SIZE,
backlinksRowsFiltersSchema, backlinksRowsFiltersSchema,
backlinksRowsModeSchema, backlinksRowsModeSchema,
backlinksRowsSortFieldSchema, backlinksRowsSortFieldSchema,
backlinksScopeWithLegacySchema,
backlinksSortOrderSchema, backlinksSortOrderSchema,
backlinksTargetScopeSchema, resolveBacklinksScope,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { researchScopeSchema } from "@/shared/researchScope";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
const inputSchema = { const inputSchema = {
projectId: projectIdSchema, projectId: projectIdSchema,
@ -29,11 +33,9 @@ const inputSchema = {
.describe( .describe(
"Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').", "Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').",
), ),
scope: backlinksTargetScopeSchema scope: backlinksScopeWithLegacySchema
.optional() .optional()
.describe( .describe(BACKLINKS_SCOPE_DESCRIPTION),
"'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.",
),
page: z page: z
.number() .number()
.int() .int()
@ -124,6 +126,8 @@ export const getBacklinksProfileTool = {
"Returns one bounded page of detailed backlink rows for a domain or page: linking URLs, target URLs, anchors, dofollow/nofollow, authority/spam signals, and lost/broken status. Supports filters, sorting, one_per_domain/as_is mode, and pagination. Charges credits (~30 per page typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.", "Returns one bounded page of detailed backlink rows for a domain or page: linking URLs, target URLs, anchors, dofollow/nofollow, authority/spam signals, and lost/broken status. Supports filters, sorting, one_per_domain/as_is mode, and pagination. Charges credits (~30 per page typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
inputSchema, inputSchema,
outputSchema: { outputSchema: {
target: z.string(),
scope: researchScopeSchema,
backlinks: backlinksProfileOutputSchema, backlinks: backlinksProfileOutputSchema,
...optionalMetaOutputSchema, ...optionalMetaOutputSchema,
}, },
@ -138,7 +142,7 @@ export const getBacklinksProfileTool = {
// backlinksRowsPageRequestSchema), so pass them straight through. // backlinksRowsPageRequestSchema), so pass them straight through.
const request = { const request = {
target: args.target, target: args.target,
scope: args.scope, scope: args.scope ? resolveBacklinksScope(args.scope) : undefined,
page: args.page, page: args.page,
pageSize: args.pageSize, pageSize: args.pageSize,
sortField: args.sortField, sortField: args.sortField,
@ -147,13 +151,18 @@ export const getBacklinksProfileTool = {
mode: args.mode, mode: args.mode,
}; };
// The page result carries only rows, so resolve the target here to report
// the scope the rows were actually fetched with.
const target = normalizeBacklinksTarget(request.target, {
scope: request.scope,
});
const backlinks = await BacklinksService.profileBacklinksPage( const backlinks = await BacklinksService.profileBacklinksPage(
request, request,
context.billing, context.billing,
{ hideSpam: args.hideSpam ?? true }, { hideSpam: args.hideSpam ?? true },
); );
const text = [ const text = [
`Backlinks profile for ${request.target} (${request.scope ?? "domain"}):`, `Backlinks profile for ${target.displayTarget} (scope: ${target.scope}):`,
`- page: ${backlinks.page}`, `- page: ${backlinks.page}`,
`- page size: ${backlinks.pageSize}`, `- page size: ${backlinks.pageSize}`,
`- rows returned: ${backlinks.rows.length}`, `- rows returned: ${backlinks.rows.length}`,
@ -171,9 +180,13 @@ export const getBacklinksProfileTool = {
context, context,
args.projectId, args.projectId,
`/p/${args.projectId}/backlinks`, `/p/${args.projectId}/backlinks`,
{ target: request.target, scope: request.scope }, { target: request.target, scope: target.scope },
), ),
structuredContent: { backlinks }, structuredContent: {
target: target.displayTarget,
scope: target.scope,
backlinks,
},
}); });
}), }),
}; };

View File

@ -22,6 +22,11 @@ import {
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
} from "@/server/mcp/schemas"; } from "@/server/mcp/schemas";
import {
RESEARCH_SCOPE_PARAM_DESCRIPTION,
researchScopeSchema,
} from "@/shared/researchScope";
import { parseResearchTargetOrThrow } from "@/server/lib/domainUtils";
const SUGGESTION_COLUMNS: McpTableColumn<unknown>[] = [ const SUGGESTION_COLUMNS: McpTableColumn<unknown>[] = [
{ header: "keyword", value: (row) => readPath(row, "keyword") }, { header: "keyword", value: (row) => readPath(row, "keyword") },
@ -35,7 +40,13 @@ const inputSchema = {
domain: z domain: z
.string() .string()
.min(1) .min(1)
.describe("Competitor or reference domain to extract keywords from."), .max(2048)
.describe(
"Competitor or reference domain or URL to extract keywords from.",
),
scope: researchScopeSchema
.optional()
.describe(RESEARCH_SCOPE_PARAM_DESCRIPTION),
locationCode: locationCodeSchema.optional(), locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(), languageCode: languageCodeSchema.optional(),
} as const; } as const;
@ -51,6 +62,8 @@ export const getDomainKeywordSuggestionsTool = {
inputSchema, inputSchema,
outputSchema: { outputSchema: {
keywords: z.array(looseObjectOutputSchema), keywords: z.array(looseObjectOutputSchema),
target: z.string().optional(),
scope: researchScopeSchema.optional(),
...optionalMetaOutputSchema, ...optionalMetaOutputSchema,
}, },
annotations: { annotations: {
@ -69,6 +82,7 @@ export const getDomainKeywordSuggestionsTool = {
const keywords = await DomainService.getSuggestedKeywords( const keywords = await DomainService.getSuggestedKeywords(
{ {
domain: args.domain, domain: args.domain,
scope: args.scope,
locationCode, locationCode,
languageCode, languageCode,
organizationId: context.auth.organizationId, organizationId: context.auth.organizationId,
@ -76,10 +90,15 @@ export const getDomainKeywordSuggestionsTool = {
}, },
context.billing, context.billing,
); );
// The service already parsed the same input, so this cannot throw here.
const parsed = parseResearchTargetOrThrow(args.domain, args.scope);
const target = parsed.display;
const scope = parsed.scope;
const targetLabel = `${target} (scope: ${scope})`;
const text = const text =
keywords.length === 0 keywords.length === 0
? `No ranked keywords found for ${args.domain}.` ? `No ranked keywords found for ${targetLabel}.`
: `Keywords for ${args.domain} (${keywords.length}):\n${formatMcpTable(keywords, SUGGESTION_COLUMNS)}`; : `Keywords for ${targetLabel} (${keywords.length}):\n${formatMcpTable(keywords, SUGGESTION_COLUMNS)}`;
return mcpResponse({ return mcpResponse({
text, text,
meta: buildProjectMeta( meta: buildProjectMeta(
@ -87,10 +106,11 @@ export const getDomainKeywordSuggestionsTool = {
args.projectId, args.projectId,
`/p/${args.projectId}/domain`, `/p/${args.projectId}/domain`,
{ {
domain: args.domain, domain: target,
...(scope ? { scope } : {}),
}, },
), ),
structuredContent: { keywords }, structuredContent: { keywords, target, scope },
}); });
}), }),
}; };

View File

@ -14,15 +14,27 @@ import {
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
} from "@/server/mcp/schemas"; } from "@/server/mcp/schemas";
import {
RESEARCH_SCOPE_PARAM_DESCRIPTION,
researchScopeSchema,
} from "@/shared/researchScope";
const inputSchema = { const inputSchema = {
projectId: projectIdSchema, projectId: projectIdSchema,
domain: z.string().min(1).describe("Domain to analyze (e.g. 'example.com')."), domain: z
.string()
.min(1)
.max(2048)
.describe("Domain or URL to analyze (e.g. 'example.com')."),
scope: researchScopeSchema
.optional()
.describe(
`${RESEARCH_SCOPE_PARAM_DESCRIPTION} Overview metrics always cover the hostname plus subdomains; narrower scopes are labeled accordingly — use get_ranked_keywords with a scope for scoped keyword data.`,
),
includeSubdomains: z includeSubdomains: z
.boolean() .boolean()
.optional() .optional()
.default(false) .describe("Deprecated: use scope ('subdomains' or 'domain') instead."),
.describe("Include subdomains in the domain's metrics. Defaults to false."),
locationCode: locationCodeSchema.optional(), locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(), languageCode: languageCodeSchema.optional(),
} as const; } as const;
@ -39,6 +51,8 @@ export const getDomainOverviewTool = {
outputSchema: z outputSchema: z
.object({ .object({
domain: z.string().optional(), domain: z.string().optional(),
scope: researchScopeSchema.optional(),
displayTarget: z.string().optional(),
organicTraffic: z.number().nullable().optional(), organicTraffic: z.number().nullable().optional(),
organicKeywords: z.number().nullable().optional(), organicKeywords: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(), backlinks: z.number().nullable().optional(),
@ -59,22 +73,34 @@ export const getDomainOverviewTool = {
); );
assertLabsLocationCode(locationCode); assertLabsLocationCode(locationCode);
assertLanguageForLocation(locationCode, languageCode); assertLanguageForLocation(locationCode, languageCode);
const scope =
args.scope ??
(args.includeSubdomains == null
? undefined
: args.includeSubdomains
? "subdomains"
: "domain");
const result = await DomainService.getOverview( const result = await DomainService.getOverview(
{ {
projectId: args.projectId, projectId: args.projectId,
domain: args.domain, domain: args.domain,
includeSubdomains: args.includeSubdomains, scope,
locationCode, locationCode,
languageCode, languageCode,
}, },
context.billing, context.billing,
); );
const text = [ const text = [
`Domain: ${result.domain}`, `Target: ${result.displayTarget} (scope: ${result.scope})`,
`Organic traffic: ${result.organicTraffic ?? "?"}`, `Organic traffic: ${result.organicTraffic ?? "?"}`,
`Organic keywords: ${result.organicKeywords ?? "?"}`, `Organic keywords: ${result.organicKeywords ?? "?"}`,
`Backlinks: ${result.backlinks ?? "?"}`, `Backlinks: ${result.backlinks ?? "?"}`,
`Referring domains: ${result.referringDomains ?? "?"}`, `Referring domains: ${result.referringDomains ?? "?"}`,
...(result.scope === "subdomains"
? []
: [
"Note: overview metrics cover the whole domain including subdomains; use get_ranked_keywords with this scope for scoped keyword data.",
]),
].join("\n"); ].join("\n");
return mcpResponse({ return mcpResponse({
text, text,

View File

@ -113,6 +113,8 @@ describe("DataForSEO research tool output schemas", () => {
const schema = objectSchema(getBacklinksProfileTool.config.outputSchema); const schema = objectSchema(getBacklinksProfileTool.config.outputSchema);
const result = await schema.safeParseAsync({ const result = await schema.safeParseAsync({
target: "example.com",
scope: "domain",
backlinks: backlinkPage, backlinks: backlinkPage,
meta: { meta: {
organizationId: "org_123", organizationId: "org_123",

View File

@ -7,6 +7,7 @@ import { getRankTrackerTool } from "./get-rank-tracker";
import { getSerpResultsTool } from "./get-serp-results"; import { getSerpResultsTool } from "./get-serp-results";
import { researchKeywordsTool } from "./research-keywords"; import { researchKeywordsTool } from "./research-keywords";
import { makeToolContext, textContent } from "./tool-test-support"; import { makeToolContext, textContent } from "./tool-test-support";
import type * as backlinksTargetModule from "@/server/lib/dataforseoBacklinksTarget";
// Verifies that each tool renders its actual row data into the text content // Verifies that each tool renders its actual row data into the text content
// block (not just a count), across the tools whose data comes from OpenSEO // block (not just a count), across the tools whose data comes from OpenSEO
@ -29,9 +30,17 @@ const mocks = vi.hoisted(() => ({
})); }));
vi.mock("cloudflare:workers", () => ({ env: {} })); vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/dataforseo", () => ({ vi.mock("@/server/lib/dataforseo", async () => {
// Real target normalizer (pure, leaf module) so scope resolution in the
// backlinks tools matches production.
const targets = await vi.importActual<typeof backlinksTargetModule>(
"@/server/lib/dataforseoBacklinksTarget",
);
return {
createDataforseoClient: mocks.createDataforseoClient, createDataforseoClient: mocks.createDataforseoClient,
})); normalizeBacklinksTarget: targets.normalizeBacklinksTarget,
};
});
vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: { ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization, getProjectForOrganization: mocks.getProjectForOrganization,

View File

@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
defaultScopeForPath,
isScopeAllowedForInput,
parseResearchTarget,
urlMatchesResearchTarget,
} from "./researchScope";
function parseOk(
input: string,
scope?: Parameters<typeof parseResearchTarget>[1],
) {
const result = parseResearchTarget(input, scope);
if (!result.ok) throw new Error(`expected ok parse, got: ${result.message}`);
return result.target;
}
describe("parseResearchTarget", () => {
it("defaults a root domain to subdomains scope", () => {
const target = parseOk("Example.com");
expect(target).toMatchObject({
scope: "subdomains",
hostname: "example.com",
path: "",
display: "example.com",
});
});
it("defaults a URL with a path to subfolder scope", () => {
const target = parseOk("example.com/commercial-insurance/");
expect(target).toMatchObject({
scope: "subfolder",
path: "/commercial-insurance",
display: "example.com/commercial-insurance",
});
});
it("strips query strings and fragments without failing", () => {
const target = parseOk("https://example.com/blog?utm_source=x#section");
expect(target.path).toBe("/blog");
});
it("preserves path casing and percent encoding", () => {
const target = parseOk("example.com/Docs/%7Euser");
expect(target.path).toBe("/Docs/%7Euser");
});
it("strips www from hostname but keeps it for page URLs", () => {
const target = parseOk("www.example.com/blog", "exact_url");
expect(target.hostname).toBe("example.com");
expect(target.urlHostname).toBe("www.example.com");
});
it("keeps subdomain hostnames intact", () => {
const target = parseOk("blog.example.com", "subdomains");
expect(target.hostname).toBe("blog.example.com");
});
it("rejects subfolder for a root input instead of silently rescoping", () => {
const result = parseResearchTarget("example.com", "subfolder");
expect(result).toEqual({
ok: false,
message: "Add a path to use Subfolder (e.g. example.com/blog)",
});
});
it("allows exact_url for a root input", () => {
expect(parseOk("example.com", "exact_url").scope).toBe("exact_url");
});
it("rejects invalid hosts and credentials", () => {
expect(parseResearchTarget("example.por").ok).toBe(false);
expect(parseResearchTarget("").ok).toBe(false);
expect(parseResearchTarget("my_site.com").ok).toBe(false);
expect(parseResearchTarget("https://user:pw@example.com/x").ok).toBe(false);
});
});
describe("scope helpers", () => {
it("computes defaults from the path", () => {
expect(defaultScopeForPath("")).toBe("subdomains");
expect(defaultScopeForPath("/blog")).toBe("subfolder");
});
it("only disallows subfolder without a path", () => {
expect(isScopeAllowedForInput("subfolder", "")).toBe(false);
expect(isScopeAllowedForInput("subfolder", "/blog")).toBe(true);
expect(isScopeAllowedForInput("exact_url", "")).toBe(true);
expect(isScopeAllowedForInput("domain", "/blog")).toBe(true);
});
});
describe("urlMatchesResearchTarget", () => {
const subfolder = parseOk("example.com/blog", "subfolder");
it("matches the subfolder itself and its children", () => {
expect(
urlMatchesResearchTarget("https://example.com/blog", subfolder),
).toBe(true);
expect(
urlMatchesResearchTarget("https://example.com/blog/post?x=1", subfolder),
).toBe(true);
expect(
urlMatchesResearchTarget("https://www.example.com/blog/", subfolder),
).toBe(true);
});
it("excludes similarly named sibling paths and other hosts", () => {
expect(
urlMatchesResearchTarget("https://example.com/blogging", subfolder),
).toBe(false);
expect(
urlMatchesResearchTarget("https://sub.example.com/blog/post", subfolder),
).toBe(false);
});
it("matches exact URLs ignoring trailing slash, query, and fragment", () => {
const exact = parseOk("example.com/pricing", "exact_url");
expect(
urlMatchesResearchTarget("https://example.com/pricing/", exact),
).toBe(true);
expect(
urlMatchesResearchTarget("https://example.com/pricing?ref=x#top", exact),
).toBe(true);
expect(
urlMatchesResearchTarget("https://example.com/pricing/plans", exact),
).toBe(false);
});
it("separates domain scope from subdomains scope", () => {
const domain = parseOk("example.com", "domain");
const subs = parseOk("example.com", "subdomains");
expect(urlMatchesResearchTarget("https://blog.example.com/x", domain)).toBe(
false,
);
expect(urlMatchesResearchTarget("https://blog.example.com/x", subs)).toBe(
true,
);
expect(urlMatchesResearchTarget("https://notexample.com/x", subs)).toBe(
false,
);
});
});

234
src/shared/researchScope.ts Normal file
View File

@ -0,0 +1,234 @@
import { parse as parseTld } from "tldts";
import { z } from "zod";
/**
* True when `host` resolves to a real registrable domain (public-suffix list),
* rejecting IPs and fake TLDs like `example.por` before they reach DataForSEO.
*/
export function isValidDomainHost(host: string): boolean {
const parsed = parseTld(host, { allowPrivateDomains: true });
return (
!parsed.isIp &&
!!parsed.publicSuffix &&
(parsed.isIcann === true || parsed.isPrivate === true)
);
}
/**
* Research scope for any URL/domain input:
* - exact_url: one normalized page URL only
* - subfolder: the selected path and its children (not similarly named siblings)
* - domain: the selected hostname, excluding its subdomains
* - subdomains: the selected hostname and all of its subdomains
*/
export const RESEARCH_SCOPES = [
"exact_url",
"subfolder",
"domain",
"subdomains",
] as const;
export type ResearchScope = (typeof RESEARCH_SCOPES)[number];
export const researchScopeSchema = z.enum(RESEARCH_SCOPES);
export const RESEARCH_SCOPE_LABELS: Record<ResearchScope, string> = {
exact_url: "Exact URL",
subfolder: "Subfolder",
domain: "Domain",
subdomains: "Subdomains",
};
/** Base wording for MCP `scope` params; tools append their own caveats. */
export const RESEARCH_SCOPE_PARAM_DESCRIPTION =
"Research scope: 'domain' (hostname without subdomains), 'subdomains' (hostname plus all subdomains), 'subfolder' (path and its children), or 'exact_url' (one page). Defaults to 'subdomains' for root inputs and 'subfolder' when the input has a path.";
/** One-line explanations shown in the scope dropdown. */
export const RESEARCH_SCOPE_DESCRIPTIONS: Record<ResearchScope, string> = {
exact_url: "One page only",
subfolder: "The path and everything under it",
domain: "The hostname, without subdomains",
subdomains: "The domain plus all its subdomains",
};
/** Wildcard-style pattern examples shown under each scope option. */
export const RESEARCH_SCOPE_EXAMPLES: Record<ResearchScope, string> = {
exact_url: "example.com/path",
subfolder: "example.com/path/*",
domain: "example.com/*",
subdomains: "*.example.com/*",
};
export type ResearchTarget = {
scope: ResearchScope;
/** Lowercased hostname with a leading `www.` stripped. */
hostname: string;
/** Hostname as entered (lowercased, `www.` preserved) for building page URLs. */
urlHostname: string;
/**
* Normalized path: `""` for the root, otherwise `/like/This` casing and
* percent-encoding preserved, trailing slashes / query / fragment stripped.
*/
path: string;
/** What to show users: hostname, plus the path for URL-scoped research. */
display: string;
};
type ParseResearchTargetResult =
| { ok: true; target: ResearchTarget }
| { ok: false; message: string };
function normalizePath(pathname: string): string {
if (pathname === "/") return "";
const trimmed = pathname.replace(/\/+$/, "");
return trimmed === "" ? "" : trimmed;
}
/** A subfolder needs a non-root path; every other scope works for any input. */
export function isScopeAllowedForInput(
scope: ResearchScope,
path: string,
): boolean {
return scope !== "subfolder" || path !== "";
}
/** Root inputs default to subdomains scope; inputs with a path to subfolder. */
export function defaultScopeForPath(path: string): ResearchScope {
return path === "" ? "subdomains" : "subfolder";
}
/** Scope implied by the input itself; unparseable input falls back to domain. */
export function defaultScopeForInput(input: string): ResearchScope {
const parsed = parseResearchTarget(input);
return parsed.ok ? parsed.target.scope : "domain";
}
/**
* Scope as persisted in URLs and history: omitted when it matches the input's
* implied default, so shared links re-derive the same scope.
*/
export function toScopeSearchParam(
input: string,
scope: ResearchScope,
): ResearchScope | undefined {
return scope === defaultScopeForInput(input) ? undefined : scope;
}
export function parseResearchTarget(
input: string,
requestedScope?: ResearchScope,
): ParseResearchTargetResult {
const trimmed = input.trim();
if (!trimmed) {
return { ok: false, message: "Enter a domain or URL" };
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
return { ok: false, message: "Enter a valid domain like example.com" };
}
if (parsed.username || parsed.password) {
return {
ok: false,
message: "URLs with embedded credentials are not supported",
};
}
const urlHostname = parsed.hostname.toLowerCase();
const hostname = urlHostname.replace(/^www\./, "");
// The charset check rejects hosts like my_site.com that URL() and tldts
// accept but DataForSEO bills and fails with an opaque "Invalid Field".
if (
!hostname ||
!hostname.includes(".") ||
!/^[a-z\d.-]+$/.test(hostname) ||
!isValidDomainHost(hostname)
) {
return { ok: false, message: "Enter a valid domain like example.com" };
}
// Query strings and fragments never create separate research scopes.
const path = normalizePath(parsed.pathname);
if (requestedScope === "subfolder" && path === "") {
return {
ok: false,
message: "Add a path to use Subfolder (e.g. example.com/blog)",
};
}
const scope = requestedScope ?? defaultScopeForPath(path);
const usesPath = scope === "exact_url" || scope === "subfolder";
return {
ok: true,
target: {
scope,
hostname,
urlHostname,
path,
display: usesPath ? `${hostname}${path}` : hostname,
},
};
}
/**
* How many of DataForSEO's 8 filter conditions each scope consumes on the
* Labs endpoints (see buildRankedKeywordsScopeFilter / buildRelevantPagesScopeFilter).
* The client uses this to shrink the user-facing filter budget.
*/
export const RESEARCH_SCOPE_FILTER_SLOTS: Record<
"keywords" | "pages",
Record<ResearchScope, number>
> = {
keywords: { exact_url: 4, subfolder: 4, domain: 1, subdomains: 0 },
pages: { exact_url: 4, subfolder: 4, domain: 2, subdomains: 0 },
};
/** Conditions the backlinks subfolder url_to/url prefix group consumes. */
export const BACKLINKS_SUBFOLDER_FILTER_CONDITIONS = 4;
function hostMatches(candidateHost: string, target: ResearchTarget): boolean {
const host = candidateHost.toLowerCase().replace(/^www\./, "");
if (target.scope === "subdomains") {
return host === target.hostname || host.endsWith(`.${target.hostname}`);
}
return host === target.hostname;
}
/**
* Whether a result URL belongs to the research target. Used to post-filter
* provider rows that cannot be scoped provider-side. Subfolder matching
* includes the path and its children but excludes similarly named siblings
* (`/blog` matches `/blog/post`, not `/blogging`).
*/
export function urlMatchesResearchTarget(
url: string,
target: ResearchTarget,
): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (!hostMatches(parsed.hostname, target)) return false;
const path = normalizePath(parsed.pathname);
switch (target.scope) {
case "exact_url":
return path === target.path;
case "subfolder":
return path === target.path || path.startsWith(`${target.path}/`);
default:
return true;
}
}

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { researchScopeSchema } from "@/shared/researchScope";
/** /**
* Input + output schemas for the AI Search feature (Brand Lookup + Prompt * Input + output schemas for the AI Search feature (Brand Lookup + Prompt
@ -42,6 +43,10 @@ export const brandLookupInputSchema = z.object({
.array(z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH)) .array(z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH))
.max(BRAND_LOOKUP_MAX_COMPETITORS) .max(BRAND_LOOKUP_MAX_COMPETITORS)
.default([]), .default([]),
// Research scope for domain/URL queries. Ignored for brand keywords, which
// have no URL to scope. Omitted = derive from the query (root → domain, path
// → subfolder).
scope: researchScopeSchema.optional(),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).default("en"),
}); });
@ -114,7 +119,18 @@ const brandMonthlyVolumeSchema = z.object({
export const brandLookupResultSchema = z.object({ export const brandLookupResultSchema = z.object({
query: z.string(), query: z.string(),
detectedTargetType: z.enum(["domain", "keyword"]), detectedTargetType: z.enum(["domain", "keyword"]),
/** Hostname for domain scopes, hostname + path for URL scopes. */
resolvedTarget: z.string(), resolvedTarget: z.string(),
// Resolved scope, or null for keyword lookups. Defaulted so cache entries
// written before scopes existed still parse.
scope: researchScopeSchema.nullable().default(null),
/**
* True under exact_url/subfolder scope: the LLM mentions API has no
* URL-level targeting, so totals, per-platform counts, monthly volume and
* Share of Voice stay domain-wide and the UI must say so. Page-level rows
* are filtered to the scope.
*/
aggregatesAreDomainLevel: z.boolean().default(false),
fetchedAt: z.string(), fetchedAt: z.string(),
hasData: z.boolean(), hasData: z.boolean(),
totalMentions: z.number().int().nonnegative().nullable(), totalMentions: z.number().int().nonnegative().nullable(),
@ -255,10 +271,12 @@ export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
* is a comma-joined competitor list (route + page treat the parsed result as an * is a comma-joined competitor list (route + page treat the parsed result as an
* opaque string array). `c` accepts a raw string (from the URL) OR an array * opaque string array). `c` accepts a raw string (from the URL) OR an array
* (TanStack Router re-validates its own transformed output on navigate) same * (TanStack Router re-validates its own transformed output on navigate) same
* union pattern as `models` below. * union pattern as `models` below. `scope` is only present when it differs
* from the scope derived from `q`.
*/ */
export const brandLookupSearchSchema = z.object({ export const brandLookupSearchSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
scope: researchScopeSchema.optional().catch(undefined),
c: z c: z
.union([z.string(), z.array(z.string())]) .union([z.string(), z.array(z.string())])
.optional() .optional()

View File

@ -1,7 +1,45 @@
import { z } from "zod"; import { z } from "zod";
import {
RESEARCH_SCOPES,
RESEARCH_SCOPE_PARAM_DESCRIPTION,
type ResearchScope,
} from "@/shared/researchScope";
export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]); export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]);
export const backlinksTargetScopeSchema = z.enum(["domain", "page"]);
/**
* Backlinks uses the shared research scopes. DataForSEO has no prefix
* targeting, so subfolder rides on url_to/url prefix filters: backlink rows
* and top pages are provider-filtered, summary counts come from filtered
* backlink totals, and rank/trends/referring-domains stay unavailable.
*/
export type BacklinksTargetScope = ResearchScope;
/**
* Scope as it arrives from persisted or external state (URLs, MCP clients).
* "page" is the pre-research-scope name for exact_url and is still accepted.
*/
export const backlinksScopeWithLegacySchema = z.enum([
...RESEARCH_SCOPES,
"page",
]);
export type BacklinksScopeWithLegacy = z.infer<
typeof backlinksScopeWithLegacySchema
>;
export function resolveBacklinksScope(
scope: BacklinksScopeWithLegacy,
): ResearchScope {
return scope === "page" ? "exact_url" : scope;
}
export const backlinksScopeParamSchema =
backlinksScopeWithLegacySchema.transform(resolveBacklinksScope);
/** Shared wording for the MCP tools that take a backlinks scope. */
export const BACKLINKS_SCOPE_DESCRIPTION = `${RESEARCH_SCOPE_PARAM_DESCRIPTION} 'page' is a deprecated alias of 'exact_url'. Subfolder counts are computed from filtered backlink totals; rank, trends, and the referring-domains breakdown are unavailable for subfolders.`;
const DEFAULT_BACKLINKS_SPAM_THRESHOLD = 40; const DEFAULT_BACKLINKS_SPAM_THRESHOLD = 40;
function normalizeBacklinksSpamThreshold(value: number) { function normalizeBacklinksSpamThreshold(value: number) {
@ -33,7 +71,7 @@ export function normalizeBacklinksSpamFilterOptions(
} }
export const backlinksLookupSchema = z.object({ export const backlinksLookupSchema = z.object({
target: z.string().min(1, "Target is required").max(2048), target: z.string().min(1, "Target is required").max(2048),
scope: backlinksTargetScopeSchema.optional(), scope: backlinksScopeParamSchema.optional(),
}); });
export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({ export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
@ -182,7 +220,7 @@ export const topPagesPageRequestSchema = backlinksPageRequestBase.extend({
export const backlinksSearchSchema = z.object({ export const backlinksSearchSchema = z.object({
target: z.string().optional(), target: z.string().optional(),
scope: backlinksTargetScopeSchema.optional(), scope: backlinksScopeParamSchema.optional().catch(undefined),
tab: backlinksTabSchema.optional(), tab: backlinksTabSchema.optional(),
page: z.coerce.number().int().positive().optional().catch(undefined), page: z.coerce.number().int().positive().optional().catch(undefined),
size: z.coerce size: z.coerce
@ -204,7 +242,6 @@ export const backlinksSearchSchema = z.object({
export type BacklinksLookupInput = z.infer<typeof backlinksLookupSchema>; export type BacklinksLookupInput = z.infer<typeof backlinksLookupSchema>;
export type BacklinksTab = z.infer<typeof backlinksTabSchema>; export type BacklinksTab = z.infer<typeof backlinksTabSchema>;
export type BacklinksTargetScope = z.infer<typeof backlinksTargetScopeSchema>;
export type BacklinksSortOrder = z.infer<typeof backlinksSortOrderSchema>; export type BacklinksSortOrder = z.infer<typeof backlinksSortOrderSchema>;
export type BacklinksRowsSortField = z.infer< export type BacklinksRowsSortField = z.infer<
typeof backlinksRowsSortFieldSchema typeof backlinksRowsSortFieldSchema

View File

@ -1,5 +1,5 @@
import { parse as parseTld } from "tldts";
import { z } from "zod"; import { z } from "zod";
import { isValidDomainHost, researchScopeSchema } from "@/shared/researchScope";
/** /**
* Extract and validate a bare hostname from user input that may be a full URL. * Extract and validate a bare hostname from user input that may be a full URL.
@ -13,19 +13,6 @@ export function normalizeDomain(input: string): string {
return hostname.replace(/^www\./, ""); return hostname.replace(/^www\./, "");
} }
/**
* True when `host` resolves to a real registrable domain (public-suffix list),
* rejecting IPs and fake TLDs like `example.por` before they reach DataForSEO.
*/
export function isValidDomainHost(host: string): boolean {
const parsed = parseTld(host, { allowPrivateDomains: true });
return (
!parsed.isIp &&
!!parsed.publicSuffix &&
(parsed.isIcann === true || parsed.isPrivate === true)
);
}
/** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */ /** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */
export const domainField = z export const domainField = z
.string() .string()
@ -57,8 +44,8 @@ export const booleanSearchParamSchema = z
export const domainOverviewSchema = z.object({ export const domainOverviewSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: z.string().min(1, "Domain is required").max(255), domain: z.string().min(1, "Domain is required").max(2048),
includeSubdomains: z.boolean().default(true), scope: researchScopeSchema.optional(),
locationCode: z.number().int().positive().optional(), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(), languageCode: z.string().min(2).max(8).optional(),
}); });
@ -73,7 +60,8 @@ const domainTabs = ["keywords", "pages"] as const;
export const domainKeywordSuggestionsSchema = z.object({ export const domainKeywordSuggestionsSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: domainField, domain: z.string().min(1, "Domain is required").max(2048),
scope: researchScopeSchema.optional(),
locationCode: z.number().int().positive().optional(), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(), languageCode: z.string().min(2).max(8).optional(),
}); });
@ -117,8 +105,8 @@ export type DomainKeywordsFilters = z.infer<typeof domainKeywordsFiltersSchema>;
export const domainKeywordsPageRequestSchema = z.object({ export const domainKeywordsPageRequestSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: z.string().min(1).max(255), domain: z.string().min(1).max(2048),
includeSubdomains: z.boolean().default(true), scope: researchScopeSchema.optional(),
locationCode: z.number().int().positive().optional(), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(), languageCode: z.string().min(2).max(8).optional(),
page: z.number().int().positive().default(1), page: z.number().int().positive().default(1),
@ -139,8 +127,8 @@ const domainPagesSortModes = ["traffic", "keywords"] as const;
export const domainPagesPageRequestSchema = z.object({ export const domainPagesPageRequestSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: z.string().min(1).max(255), domain: z.string().min(1).max(2048),
includeSubdomains: z.boolean().default(true), scope: researchScopeSchema.optional(),
locationCode: z.number().int().positive().optional(), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(), languageCode: z.string().min(2).max(8).optional(),
page: z.number().int().positive().default(1), page: z.number().int().positive().default(1),
@ -169,6 +157,8 @@ const filterNumberParam = optionalSearchNumberParam;
export const domainSearchSchema = z.object({ export const domainSearchSchema = z.object({
domain: z.string().optional(), domain: z.string().optional(),
scope: researchScopeSchema.optional().catch(undefined),
/** Legacy param: pre-scope URLs encoded "Include subdomains" here. */
subdomains: booleanSearchParamSchema.optional(), subdomains: booleanSearchParamSchema.optional(),
sort: z.enum(domainSortModes).optional(), sort: z.enum(domainSortModes).optional(),
order: z.enum(domainSortOrders).optional(), order: z.enum(domainSortOrders).optional(),