Add exact URL / subfolder / domain / subdomain research scopes (EVE-56) (#487)
This commit is contained in:
parent
3df66ad9ae
commit
c8b3eb9b0a
@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
147
src/client/components/ResearchScopeSelect.tsx
Normal file
147
src/client/components/ResearchScopeSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(", ")}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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'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}
|
||||||
|
|||||||
@ -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'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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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" }),
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +1,63 @@
|
|||||||
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({
|
/**
|
||||||
target: z.string(),
|
* Before research scopes, "domain" meant the hostname plus its subdomains and
|
||||||
scope: z.enum(["domain", "page"]),
|
* "page" meant one URL. Both names survive with different meanings, so only
|
||||||
timestamp: z.number(),
|
* 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(),
|
||||||
|
scope: z.string(),
|
||||||
|
scopeVersion: z.literal(SCOPE_VERSION).optional(),
|
||||||
|
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);
|
||||||
const backlinksSearchHistoryCodec = jsonCodec(backlinksSearchHistorySchema);
|
const backlinksSearchHistoryCodec = jsonCodec(backlinksSearchHistorySchema);
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
domain: z.string(),
|
.object({
|
||||||
subdomains: z.boolean(),
|
domain: z.string(),
|
||||||
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
|
scope: researchScopeSchema.optional(),
|
||||||
tab: z.enum(["keywords", "pages"]),
|
/** Legacy field: pre-scope history encoded "Include subdomains" here. */
|
||||||
locationCode: z.number().int().positive().optional(),
|
subdomains: z.boolean().optional(),
|
||||||
timestamp: z.number(),
|
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
|
||||||
});
|
tab: z.enum(["keywords", "pages"]),
|
||||||
|
locationCode: z.number().int().positive().optional(),
|
||||||
|
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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
146
src/server/lib/dataforseo/researchScopeFilters.test.ts
Normal file
146
src/server/lib/dataforseo/researchScopeFilters.test.ts
Normal 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);
|
||||||
|
});
|
||||||
173
src/server/lib/dataforseo/researchScopeFilters.ts
Normal file
173
src/server/lib/dataforseo/researchScopeFilters.ts
Normal 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",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 {
|
|
||||||
apiTarget: normalizedTarget,
|
|
||||||
displayTarget: normalizedTarget,
|
|
||||||
scope: "page",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasExplicitProtocol || hasMeaningfulPath) {
|
|
||||||
const normalizedTarget = normalizePageTargetUrl(parsed, exactHostname);
|
|
||||||
return {
|
|
||||||
apiTarget: normalizedTarget,
|
|
||||||
displayTarget: normalizedTarget,
|
|
||||||
scope: "page",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiTarget: domainHostname,
|
apiTarget: pageUrl,
|
||||||
displayTarget: domainHostname,
|
displayTarget: pageUrl,
|
||||||
scope: "domain",
|
scope: "exact_url",
|
||||||
|
// Irrelevant for a page target; kept true so the payload is unchanged.
|
||||||
|
includeSubdomains: true,
|
||||||
|
path: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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(
|
||||||
minSearchVolume?: number;
|
args: {
|
||||||
maxRank?: number;
|
minSearchVolume?: number;
|
||||||
excludeBrandTerms?: string[];
|
maxRank?: number;
|
||||||
}) {
|
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,
|
{
|
||||||
maxRank: args.maxRank,
|
minSearchVolume: args.minSearchVolume,
|
||||||
excludeBrandTerms: args.excludeBrandTerms,
|
maxRank: args.maxRank,
|
||||||
}),
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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,35 +76,58 @@ 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)
|
||||||
...lookup,
|
: BacklinksService.profileReferringDomainsPage(
|
||||||
page: 1,
|
{
|
||||||
pageSize: 100,
|
...lookup,
|
||||||
sortField: "backlinks",
|
page: 1,
|
||||||
sortOrder: "desc",
|
pageSize: 100,
|
||||||
filters: {},
|
sortField: "backlinks",
|
||||||
},
|
sortOrder: "desc",
|
||||||
context.billing,
|
filters: {},
|
||||||
spamOptions,
|
},
|
||||||
),
|
context.billing,
|
||||||
|
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
|
||||||
? "No referring domains found."
|
? "Referring-domains breakdown unavailable for subfolder scope."
|
||||||
: `Referring domains (${topDomains.length}):\n${formatMcpTable(topDomains, REFERRING_DOMAIN_COLUMNS)}`,
|
: topDomains.length === 0
|
||||||
|
? "No referring domains found."
|
||||||
|
: `Referring domains (${topDomains.length}):\n${formatMcpTable(topDomains, REFERRING_DOMAIN_COLUMNS)}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text,
|
text,
|
||||||
@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 },
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 () => {
|
||||||
createDataforseoClient: mocks.createDataforseoClient,
|
// 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,
|
||||||
|
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,
|
||||||
|
|||||||
143
src/shared/researchScope.test.ts
Normal file
143
src/shared/researchScope.test.ts
Normal 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
234
src/shared/researchScope.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user