Stabilize SEO demand workflow UX
This commit is contained in:
parent
a5a680bf00
commit
a8f03570c6
|
|
@ -305,6 +305,23 @@ function formatCount(value: number | null) {
|
||||||
return value === null ? "n/a" : numberFormatter.format(value);
|
return value === null ? "n/a" : numberFormatter.format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatShortDuration(ms: number) {
|
||||||
|
const totalMinutes = Math.max(0, Math.ceil(ms / 60_000));
|
||||||
|
|
||||||
|
if (totalMinutes <= 1) {
|
||||||
|
return "≤1м";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours <= 0) {
|
||||||
|
return `${minutes}м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return minutes > 0 ? `${hours}ч ${minutes}м` : `${hours}ч`;
|
||||||
|
}
|
||||||
|
|
||||||
function getRussianPlural(value: number, one: string, few: string, many: string) {
|
function getRussianPlural(value: number, one: string, few: string, many: string) {
|
||||||
const normalized = Math.abs(value);
|
const normalized = Math.abs(value);
|
||||||
const lastTwo = normalized % 100;
|
const lastTwo = normalized % 100;
|
||||||
|
|
@ -4463,18 +4480,24 @@ function KeywordCurationColumn({
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeywordCurationBoard({
|
function KeywordCurationBoard({
|
||||||
|
isBusy,
|
||||||
|
isSaving,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
keywordMap,
|
keywordMap,
|
||||||
onReconfigure,
|
onReconfigure,
|
||||||
onRoleChange,
|
onRoleChange,
|
||||||
|
onSave,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
overrides,
|
overrides,
|
||||||
stageProjectId
|
stageProjectId
|
||||||
}: {
|
}: {
|
||||||
|
isBusy: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
keywordMap: KeywordMapContract;
|
keywordMap: KeywordMapContract;
|
||||||
onReconfigure: () => void;
|
onReconfigure: () => void;
|
||||||
onRoleChange: (itemId: string, role: KeywordCurationRole) => void;
|
onRoleChange: (itemId: string, role: KeywordCurationRole) => void;
|
||||||
|
onSave: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
overrides: Record<string, KeywordCurationRole>;
|
overrides: Record<string, KeywordCurationRole>;
|
||||||
stageProjectId?: string;
|
stageProjectId?: string;
|
||||||
|
|
@ -4491,7 +4514,8 @@ function KeywordCurationBoard({
|
||||||
const activeItem = activeItemId ? keywordMap.items.find((item) => item.id === activeItemId) ?? null : null;
|
const activeItem = activeItemId ? keywordMap.items.find((item) => item.id === activeItemId) ?? null : null;
|
||||||
const activeRole = activeItem ? getKeywordCurationRoleForItem(activeItem, overrides) : "validate";
|
const activeRole = activeItem ? getKeywordCurationRoleForItem(activeItem, overrides) : "validate";
|
||||||
const persistableCount = getKeywordCurationPersistableCount(keywordMap, overrides);
|
const persistableCount = getKeywordCurationPersistableCount(keywordMap, overrides);
|
||||||
const submitDisabled = isSubmitting || keywordMap.state !== "draft" || persistableCount === 0;
|
const saveDisabled = isBusy || keywordMap.state !== "draft" || persistableCount === 0;
|
||||||
|
const submitDisabled = isBusy || keywordMap.state !== "draft" || persistableCount === 0;
|
||||||
|
|
||||||
function getDropRoleAtPoint(x: number, y: number) {
|
function getDropRoleAtPoint(x: number, y: number) {
|
||||||
const element = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-keyword-drop-role]");
|
const element = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-keyword-drop-role]");
|
||||||
|
|
@ -4574,12 +4598,31 @@ function KeywordCurationBoard({
|
||||||
<div>
|
<div>
|
||||||
<strong>Этап 3 · Распределение ключей</strong>
|
<strong>Этап 3 · Распределение ключей</strong>
|
||||||
<small>Модель распределяет, пользователь правит. Неразобранные не участвуют.</small>
|
<small>Модель распределяет, пользователь правит. Неразобранные не участвуют.</small>
|
||||||
|
<small className="keyword-stage-run-date">Сформировано: {formatDate(keywordMap.generatedAt)}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="tiny-action" onClick={onReconfigure} type="button">
|
<div className="keyword-stage-actions">
|
||||||
<RefreshCw size={14} />
|
<button
|
||||||
Переконфигурировать ключи
|
aria-label="Переконфигурировать ключи"
|
||||||
</button>
|
className="keyword-stage-icon-action"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={onReconfigure}
|
||||||
|
title="Переконфигурировать ключи"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Сохранить распределение ключей"
|
||||||
|
className="keyword-stage-icon-action"
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={onSave}
|
||||||
|
title="Сохранить распределение ключей"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="spin" size={15} /> : <Save size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="keyword-curation-columns">
|
<div className="keyword-curation-columns">
|
||||||
{keywordCurationColumns.map((column) => (
|
{keywordCurationColumns.map((column) => (
|
||||||
|
|
@ -5003,15 +5046,19 @@ function getPipelineStageAccess(
|
||||||
|
|
||||||
if (stageIndex === 5) {
|
if (stageIndex === 5) {
|
||||||
return {
|
return {
|
||||||
reason: marketReady && keywordReady ? "Есть спрос и карта фраз, можно выбирать стратегию посадочных." : "Сначала нужен рыночный спрос и карта фраз.",
|
reason: strategyReady
|
||||||
status: marketReady && keywordReady ? "ready" : "locked",
|
? "Стратегия посадочных сформирована, можно открыть карту посадочных."
|
||||||
unlocked: marketReady && keywordReady
|
: marketReady && keywordReady
|
||||||
|
? "Нажми «Формировать стратегию» на этапе ключей и спроса."
|
||||||
|
: "Сначала нужен рыночный спрос и карта фраз.",
|
||||||
|
status: strategyReady ? "ready" : "locked",
|
||||||
|
unlocked: strategyReady
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stageIndex === 6) {
|
if (stageIndex === 6) {
|
||||||
return {
|
return {
|
||||||
reason: strategyReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "Сначала нужна стратегическая база и доказательства.",
|
reason: strategyReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "План правок откроется после формирования стратегии.",
|
||||||
status: strategyReady ? "ready" : "locked",
|
status: strategyReady ? "ready" : "locked",
|
||||||
unlocked: strategyReady
|
unlocked: strategyReady
|
||||||
};
|
};
|
||||||
|
|
@ -5230,6 +5277,65 @@ function getMarketStateLabel(state: MarketEnrichmentContract["state"]) {
|
||||||
return labels[state];
|
return labels[state];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WordstatQuotaIndicator({ quota }: { quota: NonNullable<MarketEnrichmentContract["wordstat"]["quota"]> }) {
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
const limit = Math.max(1, quota.limitPerHour);
|
||||||
|
const activeReleaseItems = (quota.releaseSchedule ?? []).filter((item) => {
|
||||||
|
const releasesAtMs = new Date(item.releasesAt).getTime();
|
||||||
|
|
||||||
|
return Number.isFinite(releasesAtMs) && releasesAtMs > nowMs;
|
||||||
|
});
|
||||||
|
const scheduledUsed = activeReleaseItems.reduce((sum, item) => sum + Math.max(0, item.requestCount), 0);
|
||||||
|
const hasReleaseSchedule = (quota.releaseSchedule ?? []).length > 0;
|
||||||
|
const fallbackResetMs = quota.resetAt ? new Date(quota.resetAt).getTime() - nowMs : 0;
|
||||||
|
const fallbackRemaining =
|
||||||
|
quota.resetAt && fallbackResetMs <= 0 && quota.usedInWindow > 0 ? limit : quota.remainingInWindow;
|
||||||
|
const used = Math.max(0, hasReleaseSchedule ? scheduledUsed : limit - Math.max(0, fallbackRemaining));
|
||||||
|
const remaining = Math.max(0, Math.min(limit, limit - used));
|
||||||
|
const availablePercent = Math.max(0, Math.min(100, Math.round((remaining / limit) * 100)));
|
||||||
|
const nextReleaseMs =
|
||||||
|
activeReleaseItems
|
||||||
|
.map((item) => new Date(item.releasesAt).getTime())
|
||||||
|
.filter((releasesAtMs) => Number.isFinite(releasesAtMs) && releasesAtMs > nowMs)
|
||||||
|
.sort((left, right) => left - right)[0] ?? null;
|
||||||
|
const resetMs = nextReleaseMs ? nextReleaseMs - nowMs : fallbackResetMs;
|
||||||
|
const resetLabel = nextReleaseMs || quota.resetAt
|
||||||
|
? resetMs > 0
|
||||||
|
? `восст. ${formatShortDuration(resetMs)}`
|
||||||
|
: "окно свободно"
|
||||||
|
: "окно свободно";
|
||||||
|
const tone = remaining <= 0 ? "blocked" : remaining <= Math.ceil(limit * 0.15) ? "tight" : "ok";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = window.setInterval(() => setNowMs(Date.now()), 5_000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label={`Wordstat quota: доступно ${remaining} из ${limit}`}
|
||||||
|
className={`wordstat-quota-strip ${tone}`}
|
||||||
|
style={{ "--wordstat-quota-left": `${availablePercent}%` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="wordstat-quota-head">
|
||||||
|
<span>Wordstat</span>
|
||||||
|
<strong>
|
||||||
|
{remaining}/{limit}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="wordstat-quota-bar" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className="wordstat-quota-meta">
|
||||||
|
<span>{availablePercent}% доступно</span>
|
||||||
|
<span>{used} использ.</span>
|
||||||
|
<span>{resetLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getAnchorReviewStateLabel(state: AnchorReviewContract["state"]) {
|
function getAnchorReviewStateLabel(state: AnchorReviewContract["state"]) {
|
||||||
const labels = {
|
const labels = {
|
||||||
approved: "зафиксировано",
|
approved: "зафиксировано",
|
||||||
|
|
@ -8515,6 +8621,7 @@ export function App() {
|
||||||
const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState<string | null>(null);
|
const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState<string | null>(null);
|
||||||
const [reviewingKeywordCleaningProjectId, setReviewingKeywordCleaningProjectId] = useState<string | null>(null);
|
const [reviewingKeywordCleaningProjectId, setReviewingKeywordCleaningProjectId] = useState<string | null>(null);
|
||||||
const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState<string | null>(null);
|
const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState<string | null>(null);
|
||||||
|
const [persistingKeywordMapMode, setPersistingKeywordMapMode] = useState<"save" | "strategy" | null>(null);
|
||||||
const [materializingTargetKey, setMaterializingTargetKey] = useState<string | null>(null);
|
const [materializingTargetKey, setMaterializingTargetKey] = useState<string | null>(null);
|
||||||
const [buildingPatchProjectId, setBuildingPatchProjectId] = useState<string | null>(null);
|
const [buildingPatchProjectId, setBuildingPatchProjectId] = useState<string | null>(null);
|
||||||
const [runningPatchDryRunKey, setRunningPatchDryRunKey] = useState<string | null>(null);
|
const [runningPatchDryRunKey, setRunningPatchDryRunKey] = useState<string | null>(null);
|
||||||
|
|
@ -10932,6 +11039,7 @@ export function App() {
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
setPersistingKeywordMapProjectId(project.id);
|
setPersistingKeywordMapProjectId(project.id);
|
||||||
|
setPersistingKeywordMapMode(input?.jumpToStrategy ? "strategy" : "save");
|
||||||
setProjectActionError(null);
|
setProjectActionError(null);
|
||||||
setProjectActionMessage(null);
|
setProjectActionMessage(null);
|
||||||
setProjectActionProjectId(project.id);
|
setProjectActionProjectId(project.id);
|
||||||
|
|
@ -11000,6 +11108,7 @@ export function App() {
|
||||||
setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions."));
|
setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions."));
|
||||||
} finally {
|
} finally {
|
||||||
setPersistingKeywordMapProjectId(null);
|
setPersistingKeywordMapProjectId(null);
|
||||||
|
setPersistingKeywordMapMode(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12425,6 +12534,29 @@ export function App() {
|
||||||
? activeContextReviewRaw
|
? activeContextReviewRaw
|
||||||
: null;
|
: null;
|
||||||
const activeMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null;
|
const activeMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null;
|
||||||
|
const activeQuotaProjectId = activeProject?.id ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeStageIndex !== 4 || !activeQuotaProjectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void fetchLatestMarketEnrichment(activeQuotaProjectId)
|
||||||
|
.then((result) => {
|
||||||
|
setMarketEnrichments((currentEnrichments) => ({
|
||||||
|
...currentEnrichments,
|
||||||
|
[activeQuotaProjectId]: result.marketEnrichment
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Quota refresh is informational; action handlers surface real failures.
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [activeQuotaProjectId, activeStageIndex]);
|
||||||
|
|
||||||
const activeKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null;
|
const activeKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null;
|
||||||
const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null;
|
const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null;
|
||||||
const activeStrategySynthesis = activeProject ? (strategySyntheses[activeProject.id] ?? null) : null;
|
const activeStrategySynthesis = activeProject ? (strategySyntheses[activeProject.id] ?? null) : null;
|
||||||
|
|
@ -12812,6 +12944,7 @@ export function App() {
|
||||||
activeKeywordStrategyPersistableCount > 0 &&
|
activeKeywordStrategyPersistableCount > 0 &&
|
||||||
!isPersistingActiveKeywordMap
|
!isPersistingActiveKeywordMap
|
||||||
);
|
);
|
||||||
|
const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy";
|
||||||
const topbarStageActions =
|
const topbarStageActions =
|
||||||
activeProject && activeProjectScanSummary && activeStageIndex === 1 ? (
|
activeProject && activeProjectScanSummary && activeStageIndex === 1 ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -12891,7 +13024,7 @@ export function App() {
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{isPersistingActiveKeywordMap ? <Loader2 className="spin" size={14} /> : <GitBranch size={14} />}
|
{isSubmittingActiveKeywordStrategy ? <Loader2 className="spin" size={14} /> : <GitBranch size={14} />}
|
||||||
Формировать стратегию
|
Формировать стратегию
|
||||||
</button>
|
</button>
|
||||||
) : activeKeywordWorkflowStep === "analysis" && activeAnchorReviewReady ? (
|
) : activeKeywordWorkflowStep === "analysis" && activeAnchorReviewReady ? (
|
||||||
|
|
@ -13798,6 +13931,9 @@ export function App() {
|
||||||
</div>
|
</div>
|
||||||
{activeProject ? (
|
{activeProject ? (
|
||||||
<div className="seo-work-panel-toolbar">
|
<div className="seo-work-panel-toolbar">
|
||||||
|
{activeStageIndex === 4 && activeMarketEnrichment?.wordstat.quota ? (
|
||||||
|
<WordstatQuotaIndicator quota={activeMarketEnrichment.wordstat.quota} />
|
||||||
|
) : null}
|
||||||
{topbarStageActions}
|
{topbarStageActions}
|
||||||
<div className="seo-work-panel-actions">
|
<div className="seo-work-panel-actions">
|
||||||
{activeStageIndex === 5 ? (
|
{activeStageIndex === 5 ? (
|
||||||
|
|
@ -14167,6 +14303,8 @@ export function App() {
|
||||||
const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id;
|
const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id;
|
||||||
const isReviewingKeywordCleaning = reviewingKeywordCleaningProjectId === project.id;
|
const isReviewingKeywordCleaning = reviewingKeywordCleaningProjectId === project.id;
|
||||||
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id;
|
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id;
|
||||||
|
const isSavingKeywordMap = isPersistingKeywordMap && persistingKeywordMapMode === "save";
|
||||||
|
const isSubmittingKeywordStrategy = isPersistingKeywordMap && persistingKeywordMapMode === "strategy";
|
||||||
const isBuildingPatch = buildingPatchProjectId === project.id;
|
const isBuildingPatch = buildingPatchProjectId === project.id;
|
||||||
const isRunningPatchDryRun = patchArtifact
|
const isRunningPatchDryRun = patchArtifact
|
||||||
? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}`
|
? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}`
|
||||||
|
|
@ -14301,7 +14439,7 @@ export function App() {
|
||||||
{
|
{
|
||||||
detail:
|
detail:
|
||||||
seoStrategy.opportunities[0]?.targetPath ??
|
seoStrategy.opportunities[0]?.targetPath ??
|
||||||
currentStrategyNarrative?.landingPlan.find((landing) => landing.targetPath)?.targetPath ??
|
currentStrategyNarrative?.landingPlan?.find((landing) => landing.targetPath)?.targetPath ??
|
||||||
"посадочная ещё не выбрана",
|
"посадочная ещё не выбрана",
|
||||||
label: "Страницы",
|
label: "Страницы",
|
||||||
tone: seoStrategy.opportunities.length > 0 ? ("good" as const) : ("warning" as const),
|
tone: seoStrategy.opportunities.length > 0 ? ("good" as const) : ("warning" as const),
|
||||||
|
|
@ -17133,6 +17271,7 @@ export function App() {
|
||||||
<div>
|
<div>
|
||||||
<strong>Этап 2 · Анализ выбранных ключей</strong>
|
<strong>Этап 2 · Анализ выбранных ключей</strong>
|
||||||
<small>Wordstat и внутренняя нормализация показывают, какие фразы подтверждены спросом.</small>
|
<small>Wordstat и внутренняя нормализация показывают, какие фразы подтверждены спросом.</small>
|
||||||
|
<small className="keyword-stage-run-date">Прогон: {formatDate(marketEnrichment.generatedAt)}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`market-state-card ${marketEnrichment.state}`}>
|
<div className={`market-state-card ${marketEnrichment.state}`}>
|
||||||
|
|
@ -17484,10 +17623,17 @@ export function App() {
|
||||||
|
|
||||||
{keywordMap && keywordMap.items.length > 0 ? (
|
{keywordMap && keywordMap.items.length > 0 ? (
|
||||||
<KeywordCurationBoard
|
<KeywordCurationBoard
|
||||||
isSubmitting={isPersistingKeywordMap}
|
isBusy={isPersistingKeywordMap}
|
||||||
|
isSaving={isSavingKeywordMap}
|
||||||
|
isSubmitting={isSubmittingKeywordStrategy}
|
||||||
keywordMap={keywordMap}
|
keywordMap={keywordMap}
|
||||||
onReconfigure={() => handleKeywordReconfigure(project.id)}
|
onReconfigure={() => handleKeywordReconfigure(project.id)}
|
||||||
onRoleChange={(itemId, role) => handleKeywordCurationRoleChange(project.id, itemId, role)}
|
onRoleChange={(itemId, role) => handleKeywordCurationRoleChange(project.id, itemId, role)}
|
||||||
|
onSave={() =>
|
||||||
|
void handleApproveKeywordMapDecisions(project, {
|
||||||
|
roleOverrides: getKeywordCurationRoleOverrides(keywordMap, keywordCurationOverrides)
|
||||||
|
})
|
||||||
|
}
|
||||||
onSubmit={() =>
|
onSubmit={() =>
|
||||||
void handleApproveKeywordMapDecisions(project, {
|
void handleApproveKeywordMapDecisions(project, {
|
||||||
jumpToStrategy: true,
|
jumpToStrategy: true,
|
||||||
|
|
@ -17857,7 +18003,7 @@ export function App() {
|
||||||
<GitBranch size={18} />
|
<GitBranch size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{seoStrategy ? (
|
{seoStrategy && seoStrategy.state !== "blocked" ? (
|
||||||
<>
|
<>
|
||||||
<section className="strategy-cockpit" aria-label="Стратегия и карта посадочных">
|
<section className="strategy-cockpit" aria-label="Стратегия и карта посадочных">
|
||||||
<div className="strategy-decision-hero">
|
<div className="strategy-decision-hero">
|
||||||
|
|
@ -19536,6 +19682,12 @@ export function App() {
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</>
|
</>
|
||||||
|
) : seoStrategy ? (
|
||||||
|
<div className="empty-state semantic-empty">
|
||||||
|
<GitBranch size={18} />
|
||||||
|
Стратегия ещё не сформирована: вернись на этап “Ключи и спрос”, сохрани распределение и нажми
|
||||||
|
“Формировать стратегию”.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state semantic-empty">
|
<div className="empty-state semantic-empty">
|
||||||
<GitBranch size={18} />
|
<GitBranch size={18} />
|
||||||
|
|
|
||||||
|
|
@ -910,6 +910,27 @@ export type MarketEnrichmentContract = {
|
||||||
projectOntologyVersionId: string | null;
|
projectOntologyVersionId: string | null;
|
||||||
}>;
|
}>;
|
||||||
wordstat: {
|
wordstat: {
|
||||||
|
quota: {
|
||||||
|
provider: string;
|
||||||
|
mode: string;
|
||||||
|
limitPerHour: number;
|
||||||
|
limitPerSecond: number;
|
||||||
|
usedInWindow: number;
|
||||||
|
remainingInWindow: number;
|
||||||
|
windowMinutes: number;
|
||||||
|
windowStartedAt: string | null;
|
||||||
|
resetAt: string | null;
|
||||||
|
nextAvailableAt: string;
|
||||||
|
calculatedAt: string;
|
||||||
|
utilizationPercent: number;
|
||||||
|
releaseSchedule: Array<{
|
||||||
|
jobId: string;
|
||||||
|
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
||||||
|
requestCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
releasesAt: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
latestJob: {
|
latestJob: {
|
||||||
id: string;
|
id: string;
|
||||||
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
||||||
|
|
@ -934,6 +955,10 @@ export type MarketEnrichmentContract = {
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
relatedCount: number;
|
relatedCount: number;
|
||||||
|
clusterId: string | null;
|
||||||
|
clusterTitle: string | null;
|
||||||
|
priority: "high" | "medium" | "low" | null;
|
||||||
|
seedSource: "detected" | "ontology" | string | null;
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
}>;
|
}>;
|
||||||
topResults: Array<{
|
topResults: Array<{
|
||||||
|
|
@ -943,6 +968,10 @@ export type MarketEnrichmentContract = {
|
||||||
frequency: number | null;
|
frequency: number | null;
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
sourceClusterId: string | null;
|
||||||
|
sourceClusterTitle: string | null;
|
||||||
|
sourcePriority: "high" | "medium" | "low" | null;
|
||||||
|
sourceSeedSource: "detected" | "ontology" | string | null;
|
||||||
}>;
|
}>;
|
||||||
summary: {
|
summary: {
|
||||||
collectedSeedCount: number;
|
collectedSeedCount: number;
|
||||||
|
|
|
||||||
|
|
@ -2919,6 +2919,41 @@ input {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keyword-stage-heading .keyword-stage-run-date {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage-icon-action {
|
||||||
|
display: grid;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: rgba(10, 12, 14, 0.84);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94), 0 10px 30px rgba(24, 32, 29, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage-icon-action:hover {
|
||||||
|
background: rgba(24, 24, 24, 0.94);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage-icon-action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
.anchor-review-workspace {
|
.anchor-review-workspace {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
@ -2958,13 +2993,14 @@ input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-board {
|
.keyword-curation-board {
|
||||||
|
--keyword-curation-radius: var(--launcher-radius-card, 1.35rem);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--launcher-radius-card, 1.35rem);
|
border-radius: var(--keyword-curation-radius);
|
||||||
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2987,9 +3023,9 @@ input {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
color: #2e5a67;
|
color: rgba(8, 8, 10, 0.78);
|
||||||
background: rgba(46, 90, 103, 0.08);
|
background: rgba(8, 8, 10, 0.07);
|
||||||
border: 1px solid rgba(46, 90, 103, 0.14);
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
|
@ -3035,15 +3071,15 @@ input {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: rgba(24, 32, 29, 0.035);
|
background: rgba(24, 32, 29, 0.035);
|
||||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||||
border-radius: 8px;
|
border-radius: var(--keyword-curation-radius);
|
||||||
transition:
|
transition:
|
||||||
background-color 140ms ease,
|
background-color 140ms ease,
|
||||||
border-color 140ms ease;
|
border-color 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-column.over {
|
.keyword-curation-column.over {
|
||||||
background: rgba(46, 90, 103, 0.07);
|
background: rgba(8, 8, 10, 0.055);
|
||||||
border-color: rgba(46, 90, 103, 0.22);
|
border-color: rgba(8, 8, 10, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-column-head {
|
.keyword-curation-column-head {
|
||||||
|
|
@ -3104,7 +3140,7 @@ input {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||||
border-radius: 8px;
|
border-radius: var(--keyword-curation-radius);
|
||||||
box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08);
|
box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
@ -3133,7 +3169,7 @@ input {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(255, 255, 255, 0.52);
|
background: rgba(255, 255, 255, 0.52);
|
||||||
border: 1px dashed rgba(24, 32, 29, 0.14);
|
border: 1px dashed rgba(24, 32, 29, 0.14);
|
||||||
border-radius: 8px;
|
border-radius: var(--keyword-curation-radius);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 820;
|
font-weight: 820;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -14153,6 +14189,7 @@ body:has(.seo-launcher-shell) {
|
||||||
|
|
||||||
/* Work panels: the header is part of the same surface, not a separate card. */
|
/* Work panels: the header is part of the same surface, not a separate card. */
|
||||||
.seo-launcher-shell.project-open .topbar {
|
.seo-launcher-shell.project-open .topbar {
|
||||||
|
position: relative !important;
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: minmax(0, 1fr) auto !important;
|
grid-template-columns: minmax(0, 1fr) auto !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
|
|
@ -14214,6 +14251,84 @@ body:has(.seo-launcher-shell) {
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-strip {
|
||||||
|
--wordstat-quota-left: 100%;
|
||||||
|
display: grid;
|
||||||
|
width: clamp(15rem, 24vw, 22rem);
|
||||||
|
min-width: 13.5rem;
|
||||||
|
gap: 0.24rem;
|
||||||
|
color: rgba(8, 8, 10, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-stage-5 .wordstat-quota-strip {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-head,
|
||||||
|
.wordstat-quota-meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-head span,
|
||||||
|
.wordstat-quota-meta span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-head span {
|
||||||
|
color: rgba(8, 8, 10, 0.54);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 860;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-head strong {
|
||||||
|
color: rgba(8, 8, 10, 0.92);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 920;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 0.27rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(8, 8, 10, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-bar span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: var(--wordstat-quota-left);
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #e83e8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-strip.tight .wordstat-quota-bar span {
|
||||||
|
background: #e83e8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-strip.blocked .wordstat-quota-bar span {
|
||||||
|
background: #e83e8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordstat-quota-meta {
|
||||||
|
color: rgba(8, 8, 10, 0.52);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.seo-topbar-primary-action {
|
.seo-topbar-primary-action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 2.65rem;
|
min-height: 2.65rem;
|
||||||
|
|
@ -14267,6 +14382,21 @@ body:has(.seo-launcher-shell) {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.seo-stage-5 .seo-topbar-keyword-actions > button {
|
||||||
|
width: 3rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-stage-5 .seo-topbar-keyword-actions > button svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.seo-topbar-workspace-actions em {
|
.seo-topbar-workspace-actions em {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 1.34rem;
|
min-width: 1.34rem;
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ export type KeywordCleaningModelTaskContract = {
|
||||||
frequency: number | null;
|
frequency: number | null;
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
sourceClusterId: string | null;
|
||||||
|
sourceClusterTitle: string | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
|
|
@ -198,13 +200,27 @@ type KeywordCleaningRunRow = {
|
||||||
|
|
||||||
type SeedQueueItem = MarketEnrichmentContract["seedQueue"][number];
|
type SeedQueueItem = MarketEnrichmentContract["seedQueue"][number];
|
||||||
type WordstatTopResult = MarketEnrichmentContract["wordstat"]["topResults"][number];
|
type WordstatTopResult = MarketEnrichmentContract["wordstat"]["topResults"][number];
|
||||||
|
type RelatedSourceSeed = Pick<
|
||||||
|
SeedQueueItem,
|
||||||
|
"clusterId" | "clusterTitle" | "normalization" | "phrase" | "priority" | "projectOntologyVersionId" | "source"
|
||||||
|
>;
|
||||||
|
|
||||||
const HARD_TRASH_PATTERNS = [
|
const HARD_TRASH_PATTERNS = [
|
||||||
/(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i,
|
/(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i,
|
||||||
/(^|\s)(реферат|курсовая|диплом|презентация|скачать|pdf|книга)(\s|$)/i,
|
/(^|\s)(реферат|курсовая|диплом|презентация|скачать|pdf|книга)(\s|$)/i,
|
||||||
/(^|\s)(договор|образец|бланк|закон|гост|оквэд)(\s|$)/i
|
/(^|\s)(образец|бланк)\s+договор/i,
|
||||||
|
/(^|\s)(закон|гост|оквэд)(\s|$)/i
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CONSUMER_AI_NOISE_PATTERNS = [
|
||||||
|
/(^|\s)(бесплатн[а-яёa-z0-9-]*|онлайн|чат|бот|ответ[а-яёa-z0-9-]*|написать|написание|текст[а-яёa-z0-9-]*|песн[а-яёa-z0-9-]*|музык[а-яёa-z0-9-]*|фильм[а-яёa-z0-9-]*|фото|картинк[а-яёa-z0-9-]*|изображен[а-яёa-z0-9-]*|видео|порно|секс|таро|гороскоп)(\s|$)/i,
|
||||||
|
/нейросет[а-яёa-z0-9-]*\s+(бесплатн[а-яёa-z0-9-]*|онлайн|для\s+текст[а-яёa-z0-9-]*|для\s+картин[а-яёa-z0-9-]*)/i
|
||||||
|
];
|
||||||
|
|
||||||
|
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
||||||
|
const B2B_KEYWORD_FACET_PATTERN =
|
||||||
|
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
|
||||||
|
|
||||||
const ARTICLE_PATTERNS = [
|
const ARTICLE_PATTERNS = [
|
||||||
/(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
|
/(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
|
||||||
/(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i
|
/(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i
|
||||||
|
|
@ -549,7 +565,11 @@ function enforceKeywordCleaningSemanticGuard(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTargetPath(seed: SeedQueueItem, briefPhraseMap: Map<string, string>, briefClusterMap: Map<string, string>) {
|
function getTargetPath(
|
||||||
|
seed: Pick<SeedQueueItem, "clusterId" | "normalization" | "phrase">,
|
||||||
|
briefPhraseMap: Map<string, string>,
|
||||||
|
briefClusterMap: Map<string, string>
|
||||||
|
) {
|
||||||
const directTarget = briefPhraseMap.get(normalizePhrase(seed.phrase));
|
const directTarget = briefPhraseMap.get(normalizePhrase(seed.phrase));
|
||||||
|
|
||||||
if (directTarget) {
|
if (directTarget) {
|
||||||
|
|
@ -656,7 +676,7 @@ function buildSeedItem(
|
||||||
|
|
||||||
function buildRelatedItem(
|
function buildRelatedItem(
|
||||||
result: WordstatTopResult,
|
result: WordstatTopResult,
|
||||||
sourceSeed: SeedQueueItem,
|
sourceSeed: RelatedSourceSeed,
|
||||||
index: number,
|
index: number,
|
||||||
briefPhraseMap: Map<string, string>,
|
briefPhraseMap: Map<string, string>,
|
||||||
briefClusterMap: Map<string, string>
|
briefClusterMap: Map<string, string>
|
||||||
|
|
@ -697,6 +717,25 @@ function buildRelatedItem(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRelatedSourceSeedFromWordstatResult(
|
||||||
|
result: WordstatTopResult,
|
||||||
|
projectOntologyVersionId: string | null
|
||||||
|
): RelatedSourceSeed | null {
|
||||||
|
if (!result.sourceClusterId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clusterId: result.sourceClusterId,
|
||||||
|
clusterTitle: result.sourceClusterTitle ?? result.sourcePhrase ?? result.phrase,
|
||||||
|
normalization: null,
|
||||||
|
phrase: result.sourcePhrase ?? result.phrase,
|
||||||
|
priority: result.sourcePriority ?? "medium",
|
||||||
|
projectOntologyVersionId,
|
||||||
|
source: result.sourceSeedSource === "ontology" ? "ontology" : "detected"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getBaseItems(contract: MarketEnrichmentContract) {
|
function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||||
const briefClusterMap = buildBriefClusterMap(contract);
|
const briefClusterMap = buildBriefClusterMap(contract);
|
||||||
|
|
@ -715,7 +754,9 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePhrase = result.sourcePhrase ? normalizePhrase(result.sourcePhrase) : normalizedResult;
|
const sourcePhrase = result.sourcePhrase ? normalizePhrase(result.sourcePhrase) : normalizedResult;
|
||||||
const sourceSeed = seedByPhrase.get(sourcePhrase);
|
const sourceSeed =
|
||||||
|
seedByPhrase.get(sourcePhrase) ??
|
||||||
|
buildRelatedSourceSeedFromWordstatResult(result, contract.projectOntologyVersion?.id ?? null);
|
||||||
|
|
||||||
if (!sourceSeed) {
|
if (!sourceSeed) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -832,11 +873,33 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
|
||||||
id: result.id,
|
id: result.id,
|
||||||
phrase: result.phrase,
|
phrase: result.phrase,
|
||||||
region: result.region,
|
region: result.region,
|
||||||
|
sourceClusterId: result.sourceClusterId,
|
||||||
|
sourceClusterTitle: result.sourceClusterTitle,
|
||||||
sourcePhrase: result.sourcePhrase
|
sourcePhrase: result.sourcePhrase
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConsumerAiNoisePhrase(phrase: string) {
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && hasPattern(normalizedPhrase, CONSUMER_AI_NOISE_PATTERNS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasB2BKeywordFacet(phrase: string) {
|
||||||
|
return B2B_KEYWORD_FACET_PATTERN.test(normalizePhrase(phrase));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadAiKeywordNoise(phrase: string | null) {
|
||||||
|
if (!phrase) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
|
||||||
|
}
|
||||||
|
|
||||||
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
|
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
|
||||||
const blockers: string[] = [];
|
const blockers: string[] = [];
|
||||||
const exactEvidence = item.frequency !== null;
|
const exactEvidence = item.frequency !== null;
|
||||||
|
|
@ -851,6 +914,9 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
|
||||||
const isTrash =
|
const isTrash =
|
||||||
item.marketRole === "exclude" ||
|
item.marketRole === "exclude" ||
|
||||||
hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) ||
|
hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) ||
|
||||||
|
isConsumerAiNoisePhrase(item.normalizedPhrase) ||
|
||||||
|
isBroadAiKeywordNoise(item.normalizedPhrase) ||
|
||||||
|
(item.source === "wordstat_related" && isBroadAiKeywordNoise(item.sourcePhrase)) ||
|
||||||
(item.source === "wordstat_related" && !hasContextOverlap(item.phrase, contextTokens)) ||
|
(item.source === "wordstat_related" && !hasContextOverlap(item.phrase, contextTokens)) ||
|
||||||
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
|
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
|
||||||
|
|
||||||
|
|
@ -1037,6 +1103,7 @@ function buildKeywordCleaningModelTask(
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
|
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
|
||||||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
"Не придумывать спрос без Wordstat/SERP evidence.",
|
||||||
|
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
||||||
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря или доминантный смысл approved anchors.",
|
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря или доминантный смысл approved anchors.",
|
||||||
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||||
"Не сохранять rewrite/apply decisions."
|
"Не сохранять rewrite/apply decisions."
|
||||||
|
|
|
||||||
|
|
@ -467,7 +467,13 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
||||||
const targetCounts = new Map<string, number>();
|
const targetCounts = new Map<string, number>();
|
||||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
||||||
const sourceItems = cleaning.items
|
const sourceItems = cleaning.items
|
||||||
.filter((item) => item.decision !== "trash")
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.decision !== "trash" &&
|
||||||
|
item.evidenceStatus === "collected" &&
|
||||||
|
item.frequency !== null &&
|
||||||
|
Boolean(item.wordstatResultId)
|
||||||
|
)
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
||||||
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,184 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPANSION_STOP_PARTS = new Set([
|
||||||
|
"ai",
|
||||||
|
"ии",
|
||||||
|
"искусственный интеллект",
|
||||||
|
"api",
|
||||||
|
"bpm",
|
||||||
|
"crm",
|
||||||
|
"engine",
|
||||||
|
"erp",
|
||||||
|
"hub",
|
||||||
|
"mcp",
|
||||||
|
"ops",
|
||||||
|
"3d",
|
||||||
|
"demo",
|
||||||
|
"pilot",
|
||||||
|
"workspace",
|
||||||
|
"on-prem",
|
||||||
|
"private cloud",
|
||||||
|
"данных",
|
||||||
|
"данные",
|
||||||
|
"доступы",
|
||||||
|
"интеграции",
|
||||||
|
"модули",
|
||||||
|
"платформа",
|
||||||
|
"процессы",
|
||||||
|
"система"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ALLOWED_EXPANSION_LATIN_TOKENS = new Set(["1c", "ai", "api", "bim", "bpm", "crm", "erp", "iot", "workflow"]);
|
||||||
|
|
||||||
|
const EXPANSION_SYNONYMS: Array<[RegExp, string[]]> = [
|
||||||
|
[/digital\s+twin|цифров\w*\s+двойн/i, ["цифровой двойник", "цифровые двойники", "digital twin"]],
|
||||||
|
[/\bbim\b|(^|\s)бим(\s|$)/i, ["bim", "бим"]],
|
||||||
|
[/1c|1с/i, ["1с", "1c"]],
|
||||||
|
[/закуп/i, ["закупки", "автоматизация закупок", "управление закупками"]],
|
||||||
|
[/тендер/i, ["тендеры", "тендерный помощник", "автоматизация тендеров"]],
|
||||||
|
[/юрид/i, ["юридические процессы", "юридический ассистент", "автоматизация договоров"]],
|
||||||
|
[/беспилот/i, ["беспилотная техника", "управление беспилотниками", "платформа для беспилотников"]],
|
||||||
|
[/\biot\b|интернет\s+вещ/i, ["iot", "интернет вещей", "iot платформа"]],
|
||||||
|
[/телеметр/i, ["телеметрия", "система телеметрии", "платформа телеметрии"]],
|
||||||
|
[/строител/i, ["строительство", "автоматизация строительства", "строительный контроль"]],
|
||||||
|
[/эксплуатац/i, ["эксплуатация объектов", "управление эксплуатацией", "автоматизация эксплуатации"]]
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPANSION_QUERY_BLOCKLIST = [
|
||||||
|
/(^|\s)(базов[а-яёa-z0-9-]*|трендов[а-яёa-z0-9-]*|коммерческ[а-яёa-z0-9-]*)\s+интент/i,
|
||||||
|
/(^|\s)интент(:|\s|$)/i,
|
||||||
|
/(^|\s)(задач|проверок)\.$/i,
|
||||||
|
/(^|\s)(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)\s+(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)$/i,
|
||||||
|
/^(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)\s+(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)(\s|$)/i,
|
||||||
|
/[.!?:]/,
|
||||||
|
/\b[a-z]+\s+[a-z]+\s+[a-z]+\b/i
|
||||||
|
];
|
||||||
|
|
||||||
|
function wordCount(value: string) {
|
||||||
|
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values: string[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const cleanValue = normalizePhrase(value);
|
||||||
|
|
||||||
|
if (!cleanValue || seen.has(cleanValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(cleanValue);
|
||||||
|
result.push(cleanValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitExpansionParts(value: string) {
|
||||||
|
return normalizePhrase(value)
|
||||||
|
.replace(/[()]/g, " ")
|
||||||
|
.replace(/\s+(?:и|and)\s+/gi, ",")
|
||||||
|
.split(/\s*(?:,|\/|&|\+|;)\s*/i)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => wordCount(part) >= 2 || /^[a-z0-9-]{2,16}$/i.test(part))
|
||||||
|
.filter((part) => !EXPANSION_STOP_PARTS.has(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpansionSynonyms(value: string) {
|
||||||
|
return EXPANSION_SYNONYMS.flatMap(([pattern, replacements]) => (pattern.test(value) ? replacements : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSpecificExpansionFacet(phrase: string) {
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
return /бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|1с|crm|erp|bpm|workflow|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн/i.test(
|
||||||
|
normalizedPhrase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnsafeExpansionLatinToken(phrase: string) {
|
||||||
|
return phrase
|
||||||
|
.split(/\s+/)
|
||||||
|
.flatMap((token) => token.match(/[a-z0-9-]+/gi) ?? [])
|
||||||
|
.some((token) => !ALLOWED_EXPANSION_LATIN_TOKENS.has(token.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpansionPhraseVariants(phrase: string, contextText: string) {
|
||||||
|
const variants = new Set<string>();
|
||||||
|
const cleanPhrase = normalizePhrase(phrase);
|
||||||
|
const hasAiContext = /\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(contextText);
|
||||||
|
|
||||||
|
if (!cleanPhrase || EXPANSION_STOP_PARTS.has(cleanPhrase)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
variants.add(cleanPhrase);
|
||||||
|
|
||||||
|
if (!/платформ/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`платформа ${cleanPhrase}`);
|
||||||
|
variants.add(`${cleanPhrase} платформа`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/систем/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`система ${cleanPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/автоматизац/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`автоматизация ${cleanPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAiContext && !/\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`${cleanPhrase} с искусственным интеллектом`);
|
||||||
|
variants.add(`ai ${cleanPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...variants].filter(isSafeWordstatExpansionPhrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeWordstatExpansionPhrase(phrase: string) {
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
return (
|
||||||
|
wordCount(normalizedPhrase) >= 2 &&
|
||||||
|
wordCount(normalizedPhrase) <= 7 &&
|
||||||
|
/[а-яё]/i.test(normalizedPhrase) &&
|
||||||
|
normalizedPhrase !== "искусственный интеллект" &&
|
||||||
|
!/[{}[\]<>#$%^*=~`]/.test(normalizedPhrase) &&
|
||||||
|
!EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) &&
|
||||||
|
!hasUnsafeExpansionLatinToken(normalizedPhrase) &&
|
||||||
|
(!/\b(ai|ии)\b|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) ||
|
||||||
|
hasSpecificExpansionFacet(normalizedPhrase)) &&
|
||||||
|
!/(^|\s)(система|платформа)\s+(система|платформа)(\s|$)/i.test(normalizedPhrase) &&
|
||||||
|
!/искусственн[а-яёa-z0-9-]*\s+интеллект.*искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) &&
|
||||||
|
!normalizedPhrase.split(/\s+/).some((token) => /^[a-z0-9-]{1,2}$/i.test(token) && !/^(ai|bim)$/i.test(token))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterExpansionPhrases(cluster: SemanticAnalysisRun["clusters"][number], seedPhrase: string) {
|
||||||
|
const contextText = [
|
||||||
|
seedPhrase,
|
||||||
|
cluster.title,
|
||||||
|
cluster.targetIntent,
|
||||||
|
...cluster.matchedTerms,
|
||||||
|
...cluster.queryExamples,
|
||||||
|
...cluster.sourceRefs.map((ref) => ref.title),
|
||||||
|
...cluster.evidenceSnippets.slice(0, 8).flatMap((snippet) => [snippet.title, snippet.text]),
|
||||||
|
...cluster.targetPages
|
||||||
|
].join(" ");
|
||||||
|
const baseParts = unique([
|
||||||
|
...splitExpansionParts(seedPhrase),
|
||||||
|
...splitExpansionParts(cluster.title),
|
||||||
|
...splitExpansionParts(cluster.targetIntent),
|
||||||
|
...cluster.matchedTerms.flatMap(splitExpansionParts),
|
||||||
|
...getExpansionSynonyms(contextText)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return unique(baseParts.flatMap((part) => buildExpansionPhraseVariants(part, contextText))).slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
function buildWordstatSeedMap(wordstat: WordstatEvidence) {
|
function buildWordstatSeedMap(wordstat: WordstatEvidence) {
|
||||||
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result]));
|
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result]));
|
||||||
}
|
}
|
||||||
|
|
@ -271,18 +449,18 @@ function getSeedEvidenceStatus(
|
||||||
return "collected";
|
return "collected";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wordstat.latestJob?.status === "failed") {
|
if (wordstatResult && wordstatResult.relatedCount > 0) {
|
||||||
return "collection_failed";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wordstat.latestJob?.status === "done" && wordstatResult && wordstatResult.relatedCount > 0) {
|
|
||||||
return "collected_related_only";
|
return "collected_related_only";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wordstat.latestJob?.status === "done" && wordstatResult) {
|
if (wordstatResult) {
|
||||||
return "collected_no_signal";
|
return "collected_no_signal";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wordstat.latestJob?.status === "failed") {
|
||||||
|
return "collection_failed";
|
||||||
|
}
|
||||||
|
|
||||||
return getEvidenceStatus(providers, ["wordstat"]);
|
return getEvidenceStatus(providers, ["wordstat"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,6 +508,95 @@ function buildSeedQueue(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) {
|
||||||
|
return (
|
||||||
|
seed.frequency === null &&
|
||||||
|
(seed.evidenceStatus === "collected_no_signal" ||
|
||||||
|
seed.evidenceStatus === "collected_related_only" ||
|
||||||
|
seed.evidenceStatus === "collection_failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWordstatExpansionSeeds(
|
||||||
|
analysis: SemanticAnalysisRun,
|
||||||
|
market: MarketEnrichmentContract
|
||||||
|
): WordstatSeedCandidate[] {
|
||||||
|
const clusterById = new Map(analysis.clusters.map((cluster) => [cluster.id, cluster]));
|
||||||
|
const existingPhrases = new Set([
|
||||||
|
...market.seedQueue.map((seed) => normalizePhrase(seed.phrase)),
|
||||||
|
...market.wordstat.seedResults.map((result) => normalizePhrase(result.phrase)),
|
||||||
|
...market.wordstat.topResults.map((result) => normalizePhrase(result.phrase))
|
||||||
|
]);
|
||||||
|
const candidatesByCluster = new Map<string, WordstatSeedCandidate[]>();
|
||||||
|
const clusterOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const seed of market.seedQueue.filter(shouldExpandSeed)) {
|
||||||
|
const cluster = clusterById.get(seed.clusterId);
|
||||||
|
|
||||||
|
if (!cluster) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phrase of buildClusterExpansionPhrases(cluster, seed.phrase)) {
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
if (existingPhrases.has(normalizedPhrase)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPhrases.add(normalizedPhrase);
|
||||||
|
|
||||||
|
if (!candidatesByCluster.has(seed.clusterId)) {
|
||||||
|
candidatesByCluster.set(seed.clusterId, []);
|
||||||
|
clusterOrder.push(seed.clusterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterCandidates = candidatesByCluster.get(seed.clusterId);
|
||||||
|
|
||||||
|
if (!clusterCandidates || clusterCandidates.length >= 8) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterCandidates.push({
|
||||||
|
clusterId: seed.clusterId,
|
||||||
|
clusterTitle: seed.clusterTitle,
|
||||||
|
confidence: Math.max(0.42, Math.min((seed.normalization?.confidence ?? 0.6) * 0.9, 0.82)),
|
||||||
|
phrase: normalizedPhrase,
|
||||||
|
priority: seed.priority,
|
||||||
|
reason: `Expansion seed для Wordstat: исходная фраза "${seed.phrase}" не дала exact demand; аналог собран из semantic cluster/source evidence.`,
|
||||||
|
source: seed.source
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diversifiedCandidates: WordstatSeedCandidate[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; diversifiedCandidates.length < 48; index += 1) {
|
||||||
|
let added = false;
|
||||||
|
|
||||||
|
for (const clusterId of clusterOrder) {
|
||||||
|
const candidate = candidatesByCluster.get(clusterId)?.[index];
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
diversifiedCandidates.push(candidate);
|
||||||
|
added = true;
|
||||||
|
|
||||||
|
if (diversifiedCandidates.length >= 48) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!added) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diversifiedCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
function buildBriefEvidence(
|
function buildBriefEvidence(
|
||||||
analysis: SemanticAnalysisRun,
|
analysis: SemanticAnalysisRun,
|
||||||
providers: MarketProviderDescriptor[],
|
providers: MarketProviderDescriptor[],
|
||||||
|
|
@ -473,8 +740,7 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
|
||||||
const providers = await buildProviderRegistry();
|
const providers = await buildProviderRegistry();
|
||||||
const normalization = await getSeoNormalizationContract(projectId);
|
const normalization = await getSeoNormalizationContract(projectId);
|
||||||
const anchorReview = await getAnchorReviewContract(projectId);
|
const anchorReview = await getAnchorReviewContract(projectId);
|
||||||
const approvedWordstatPhrases = anchorReview.wordstatQueue.map((seed) => seed.phrase);
|
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null);
|
||||||
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases);
|
|
||||||
const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : [];
|
const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : [];
|
||||||
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
|
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
|
||||||
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
|
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
|
||||||
|
|
@ -546,6 +812,12 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await collectWordstatEvidence(projectId, analysis, normalizedSeeds);
|
await collectWordstatEvidence(projectId, analysis, normalizedSeeds);
|
||||||
|
const marketAfterBaseCollection = await getMarketEnrichmentContract(projectId);
|
||||||
|
const expansionSeeds = buildWordstatExpansionSeeds(analysis, marketAfterBaseCollection);
|
||||||
|
|
||||||
|
if (expansionSeeds.length > 0) {
|
||||||
|
await collectWordstatEvidence(projectId, analysis, [...normalizedSeeds, ...expansionSeeds]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export interface WordstatProvider {
|
||||||
collect(request: WordstatCollectRequest): Promise<WordstatCollectResult>;
|
collect(request: WordstatCollectRequest): Promise<WordstatCollectResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const YANDEX_WORDSTAT_MIN_REQUEST_INTERVAL_MS = 125;
|
||||||
|
|
||||||
export class WordstatProviderUnavailableError extends Error {
|
export class WordstatProviderUnavailableError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -68,6 +70,12 @@ export class WordstatProviderUnavailableError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getFrequencyGroup(frequency: number | null, explicitGroup?: unknown) {
|
function getFrequencyGroup(frequency: number | null, explicitGroup?: unknown) {
|
||||||
if (typeof explicitGroup === "string" && explicitGroup.trim()) {
|
if (typeof explicitGroup === "string" && explicitGroup.trim()) {
|
||||||
return explicitGroup.trim();
|
return explicitGroup.trim();
|
||||||
|
|
@ -317,7 +325,11 @@ class YandexApiWordstatProvider implements WordstatProvider {
|
||||||
this.config.authType === "iam_token" ? `Bearer ${this.config.apiToken}` : `Api-Key ${this.config.apiToken}`;
|
this.config.authType === "iam_token" ? `Bearer ${this.config.apiToken}` : `Api-Key ${this.config.apiToken}`;
|
||||||
const seedResponses = [];
|
const seedResponses = [];
|
||||||
|
|
||||||
for (const seed of request.seeds) {
|
for (const [index, seed] of request.seeds.entries()) {
|
||||||
|
if (index > 0) {
|
||||||
|
await delay(YANDEX_WORDSTAT_MIN_REQUEST_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await postJson(
|
const payload = await postJson(
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ type WordstatResultRow = {
|
||||||
id: string;
|
id: string;
|
||||||
job_id: string;
|
job_id: string;
|
||||||
seed_id: string | null;
|
seed_id: string | null;
|
||||||
|
seed_cluster_id: string | null;
|
||||||
|
seed_cluster_title: string | null;
|
||||||
|
seed_priority: "high" | "medium" | "low" | null;
|
||||||
|
seed_source: "detected" | "ontology" | string | null;
|
||||||
phrase: string;
|
phrase: string;
|
||||||
source_phrase: string | null;
|
source_phrase: string | null;
|
||||||
normalized_phrase: string | null;
|
normalized_phrase: string | null;
|
||||||
|
|
@ -60,7 +64,33 @@ type WordstatResultRow = {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
|
||||||
|
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
|
||||||
|
|
||||||
|
export type WordstatQuotaSnapshot = {
|
||||||
|
provider: string;
|
||||||
|
mode: string;
|
||||||
|
limitPerHour: number;
|
||||||
|
limitPerSecond: number;
|
||||||
|
usedInWindow: number;
|
||||||
|
remainingInWindow: number;
|
||||||
|
windowMinutes: number;
|
||||||
|
windowStartedAt: string | null;
|
||||||
|
resetAt: string | null;
|
||||||
|
nextAvailableAt: string;
|
||||||
|
calculatedAt: string;
|
||||||
|
utilizationPercent: number;
|
||||||
|
releaseSchedule: Array<{
|
||||||
|
jobId: string;
|
||||||
|
status: WordstatJobRow["status"];
|
||||||
|
requestCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
releasesAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type WordstatEvidence = {
|
export type WordstatEvidence = {
|
||||||
|
quota: WordstatQuotaSnapshot | null;
|
||||||
latestJob: {
|
latestJob: {
|
||||||
id: string;
|
id: string;
|
||||||
status: WordstatJobRow["status"];
|
status: WordstatJobRow["status"];
|
||||||
|
|
@ -85,6 +115,10 @@ export type WordstatEvidence = {
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
relatedCount: number;
|
relatedCount: number;
|
||||||
|
clusterId: string | null;
|
||||||
|
clusterTitle: string | null;
|
||||||
|
priority: "high" | "medium" | "low" | null;
|
||||||
|
seedSource: "detected" | "ontology" | string | null;
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
}>;
|
}>;
|
||||||
topResults: Array<{
|
topResults: Array<{
|
||||||
|
|
@ -94,6 +128,10 @@ export type WordstatEvidence = {
|
||||||
frequency: number | null;
|
frequency: number | null;
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
sourceClusterId: string | null;
|
||||||
|
sourceClusterTitle: string | null;
|
||||||
|
sourcePriority: "high" | "medium" | "low" | null;
|
||||||
|
sourceSeedSource: "detected" | "ontology" | string | null;
|
||||||
}>;
|
}>;
|
||||||
summary: {
|
summary: {
|
||||||
collectedSeedCount: number;
|
collectedSeedCount: number;
|
||||||
|
|
@ -405,7 +443,11 @@ function getLatestUniqueWordstatResults(results: WordstatResultRow[]) {
|
||||||
return Array.from(resultByIdentity.values()).sort(compareWordstatResultRows);
|
return Array.from(resultByIdentity.values()).sort(compareWordstatResultRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence {
|
function buildEvidence(
|
||||||
|
job: WordstatJobRow | null,
|
||||||
|
results: WordstatResultRow[],
|
||||||
|
quota: WordstatQuotaSnapshot | null
|
||||||
|
): WordstatEvidence {
|
||||||
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
||||||
const relatedCounts = new Map<string, number>();
|
const relatedCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
|
@ -416,6 +458,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
quota,
|
||||||
latestJob: job ? mapJob(job) : null,
|
latestJob: job ? mapJob(job) : null,
|
||||||
seedResults: seedRows.map((result) => ({
|
seedResults: seedRows.map((result) => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
|
|
@ -426,6 +469,10 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
||||||
frequencyGroup: result.frequency_group,
|
frequencyGroup: result.frequency_group,
|
||||||
region: result.region,
|
region: result.region,
|
||||||
relatedCount: relatedCounts.get(result.phrase) ?? 0,
|
relatedCount: relatedCounts.get(result.phrase) ?? 0,
|
||||||
|
clusterId: result.seed_cluster_id,
|
||||||
|
clusterTitle: result.seed_cluster_title,
|
||||||
|
priority: result.seed_priority,
|
||||||
|
seedSource: result.seed_source,
|
||||||
collectedAt: result.created_at.toISOString()
|
collectedAt: result.created_at.toISOString()
|
||||||
})),
|
})),
|
||||||
topResults: getRepresentativeTopResultRows(results)
|
topResults: getRepresentativeTopResultRows(results)
|
||||||
|
|
@ -435,7 +482,11 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
||||||
sourcePhrase: result.source_phrase,
|
sourcePhrase: result.source_phrase,
|
||||||
frequency: result.frequency,
|
frequency: result.frequency,
|
||||||
frequencyGroup: result.frequency_group,
|
frequencyGroup: result.frequency_group,
|
||||||
region: result.region
|
region: result.region,
|
||||||
|
sourceClusterId: result.seed_cluster_id,
|
||||||
|
sourceClusterTitle: result.seed_cluster_title,
|
||||||
|
sourcePriority: result.seed_priority,
|
||||||
|
sourceSeedSource: result.seed_source
|
||||||
})),
|
})),
|
||||||
summary: {
|
summary: {
|
||||||
collectedSeedCount: seedRows.length,
|
collectedSeedCount: seedRows.length,
|
||||||
|
|
@ -458,7 +509,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
||||||
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) {
|
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) {
|
||||||
const seedRows: SeedRow[] = [];
|
const seedRows: SeedRow[] = [];
|
||||||
|
|
||||||
for (const seed of seedCandidates.slice(0, 80)) {
|
for (const seed of seedCandidates) {
|
||||||
const inserted = await pool.query<SeedRow>(
|
const inserted = await pool.query<SeedRow>(
|
||||||
`
|
`
|
||||||
insert into seeds (
|
insert into seeds (
|
||||||
|
|
@ -548,6 +599,89 @@ async function getCollectedSeedPhraseSet(projectId: string, semanticRunId: strin
|
||||||
return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase)));
|
return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getWordstatQuotaSnapshot(provider: string, mode: string): Promise<WordstatQuotaSnapshot> {
|
||||||
|
const result = await pool.query<{
|
||||||
|
id: string;
|
||||||
|
status: WordstatJobRow["status"];
|
||||||
|
created_at: Date;
|
||||||
|
request_count: string | number | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
jsonb_array_length(requested_phrases) as request_count
|
||||||
|
from wordstat_jobs
|
||||||
|
where provider = $1
|
||||||
|
and mode = $2
|
||||||
|
and status in ('running', 'done', 'failed')
|
||||||
|
and created_at >= now() - interval '1 hour'
|
||||||
|
and jsonb_array_length(requested_phrases) > 0
|
||||||
|
order by created_at asc;
|
||||||
|
`,
|
||||||
|
[provider, mode]
|
||||||
|
);
|
||||||
|
const releaseSchedule = result.rows.map((row) => {
|
||||||
|
const createdAt = row.created_at;
|
||||||
|
const releasesAt = new Date(createdAt.getTime() + 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createdAt: createdAt.toISOString(),
|
||||||
|
jobId: row.id,
|
||||||
|
releasesAt: releasesAt.toISOString(),
|
||||||
|
requestCount: Number(row.request_count ?? 0),
|
||||||
|
status: row.status
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const usedCount = releaseSchedule.reduce((sum, item) => sum + item.requestCount, 0);
|
||||||
|
const remaining = Math.max(0, WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT - usedCount);
|
||||||
|
const calculatedAt = new Date().toISOString();
|
||||||
|
const resetAt = releaseSchedule[0]?.releasesAt ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculatedAt,
|
||||||
|
limitPerHour: WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT,
|
||||||
|
limitPerSecond: WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT,
|
||||||
|
mode,
|
||||||
|
nextAvailableAt: remaining > 0 ? calculatedAt : (resetAt ?? calculatedAt),
|
||||||
|
provider,
|
||||||
|
releaseSchedule,
|
||||||
|
remainingInWindow: remaining,
|
||||||
|
resetAt,
|
||||||
|
usedInWindow: usedCount,
|
||||||
|
utilizationPercent: Math.min(100, Math.round((usedCount / WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT) * 100)),
|
||||||
|
windowMinutes: 60,
|
||||||
|
windowStartedAt: releaseSchedule[0]?.createdAt ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWordstatQuotaSnapshotForEvidence(job: WordstatJobRow | null) {
|
||||||
|
if (job) {
|
||||||
|
return getWordstatQuotaSnapshot(job.provider, job.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await createWordstatProvider();
|
||||||
|
const status = provider.getStatus();
|
||||||
|
|
||||||
|
if (status.provider === "disabled") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getWordstatQuotaSnapshot(status.provider, status.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWordstatHourlyBudget(provider: string, mode: string) {
|
||||||
|
const snapshot = await getWordstatQuotaSnapshot(provider, mode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit: snapshot.limitPerHour,
|
||||||
|
remaining: snapshot.remainingInWindow,
|
||||||
|
resetAt: snapshot.nextAvailableAt,
|
||||||
|
usedCount: snapshot.usedInWindow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createJob(
|
async function createJob(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
semanticRunId: string,
|
semanticRunId: string,
|
||||||
|
|
@ -737,16 +871,24 @@ export async function collectWordstatEvidence(
|
||||||
|
|
||||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||||
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
||||||
const seeds = seedRows
|
const uncollectedSeedRows = seedRows.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)));
|
||||||
.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)))
|
|
||||||
.slice(0, status.maxSeedsPerRun)
|
|
||||||
.map(toWordstatSeed);
|
|
||||||
const region = "ru";
|
|
||||||
|
|
||||||
if (seeds.length === 0) {
|
if (uncollectedSeedRows.length === 0) {
|
||||||
return getLatestWordstatEvidence(projectId, analysis.runId);
|
return getLatestWordstatEvidence(projectId, analysis.runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const budget = await getWordstatHourlyBudget(status.provider, status.mode);
|
||||||
|
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining);
|
||||||
|
|
||||||
|
if (maxSeedsForRun <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Wordstat hourly quota exhausted: ${budget.usedCount}/${budget.limit} statistics requests used. Retry after ${budget.resetAt}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seeds = uncollectedSeedRows.slice(0, maxSeedsForRun).map(toWordstatSeed);
|
||||||
|
const region = "ru";
|
||||||
|
|
||||||
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
|
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
|
|
@ -778,7 +920,7 @@ export async function getLatestWordstatEvidence(
|
||||||
scopePhrases?: string[]
|
scopePhrases?: string[]
|
||||||
): Promise<WordstatEvidence> {
|
): Promise<WordstatEvidence> {
|
||||||
if (!semanticRunId) {
|
if (!semanticRunId) {
|
||||||
return buildEvidence(null, []);
|
return buildEvidence(null, [], await getWordstatQuotaSnapshotForEvidence(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobResult = await pool.query<WordstatJobRow>(
|
const jobResult = await pool.query<WordstatJobRow>(
|
||||||
|
|
@ -801,15 +943,18 @@ export async function getLatestWordstatEvidence(
|
||||||
updated_at
|
updated_at
|
||||||
from wordstat_jobs
|
from wordstat_jobs
|
||||||
where project_id = $1 and semantic_run_id = $2
|
where project_id = $1 and semantic_run_id = $2
|
||||||
order by created_at desc
|
order by
|
||||||
|
case when status = 'done' then 0 else 1 end,
|
||||||
|
created_at desc
|
||||||
limit 1;
|
limit 1;
|
||||||
`,
|
`,
|
||||||
[projectId, semanticRunId]
|
[projectId, semanticRunId]
|
||||||
);
|
);
|
||||||
const latestJob = jobResult.rows[0] ?? null;
|
const latestJob = jobResult.rows[0] ?? null;
|
||||||
|
const quota = await getWordstatQuotaSnapshotForEvidence(latestJob);
|
||||||
|
|
||||||
if (!latestJob) {
|
if (!latestJob) {
|
||||||
return buildEvidence(null, []);
|
return buildEvidence(null, [], quota);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await pool.query<WordstatResultRow>(
|
const results = await pool.query<WordstatResultRow>(
|
||||||
|
|
@ -818,6 +963,10 @@ export async function getLatestWordstatEvidence(
|
||||||
wr.id,
|
wr.id,
|
||||||
wr.job_id,
|
wr.job_id,
|
||||||
wr.seed_id,
|
wr.seed_id,
|
||||||
|
s.cluster_id as seed_cluster_id,
|
||||||
|
s.cluster_title as seed_cluster_title,
|
||||||
|
s.priority as seed_priority,
|
||||||
|
s.source as seed_source,
|
||||||
wr.phrase,
|
wr.phrase,
|
||||||
wr.source_phrase,
|
wr.source_phrase,
|
||||||
wr.normalized_phrase,
|
wr.normalized_phrase,
|
||||||
|
|
@ -829,6 +978,7 @@ export async function getLatestWordstatEvidence(
|
||||||
wr.created_at
|
wr.created_at
|
||||||
from wordstat_results wr
|
from wordstat_results wr
|
||||||
join wordstat_jobs wj on wj.id = wr.job_id
|
join wordstat_jobs wj on wj.id = wr.job_id
|
||||||
|
left join seeds s on s.id = wr.seed_id
|
||||||
where wj.project_id = $1
|
where wj.project_id = $1
|
||||||
and wj.semantic_run_id = $2
|
and wj.semantic_run_id = $2
|
||||||
and wj.status = 'done'
|
and wj.status = 'done'
|
||||||
|
|
@ -839,7 +989,7 @@ export async function getLatestWordstatEvidence(
|
||||||
const latestUniqueResults = getLatestUniqueWordstatResults(results.rows);
|
const latestUniqueResults = getLatestUniqueWordstatResults(results.rows);
|
||||||
|
|
||||||
if (!scopePhrases || scopePhrases.length === 0) {
|
if (!scopePhrases || scopePhrases.length === 0) {
|
||||||
return buildEvidence(latestJob, latestUniqueResults);
|
return buildEvidence(latestJob, latestUniqueResults, quota);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase));
|
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase));
|
||||||
|
|
@ -855,5 +1005,5 @@ export async function getLatestWordstatEvidence(
|
||||||
result_count: scopedResults.length
|
result_count: scopedResults.length
|
||||||
};
|
};
|
||||||
|
|
||||||
return buildEvidence(scopedJob, scopedResults);
|
return buildEvidence(scopedJob, scopedResults, quota);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.taskType === "seo.keyword_cleaning") {
|
if (task.taskType === "seo.keyword_cleaning") {
|
||||||
return 2400;
|
return 5200;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.taskType === "seo.serp_interpretation") {
|
if (task.taskType === "seo.serp_interpretation") {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,18 @@ export type SeoNormalizationModelTaskContract = {
|
||||||
queryExamples: string[];
|
queryExamples: string[];
|
||||||
evidenceLevel: "none" | "thin" | "moderate" | "strong";
|
evidenceLevel: "none" | "thin" | "moderate" | "strong";
|
||||||
}>;
|
}>;
|
||||||
|
verticalOpportunities: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
priority: "high" | "medium" | "low";
|
||||||
|
status: "covered" | "weak" | "missing";
|
||||||
|
targetIntent: string;
|
||||||
|
matchedTerms: string[];
|
||||||
|
queryExamples: string[];
|
||||||
|
sourceTitles: string[];
|
||||||
|
sourceSnippets: string[];
|
||||||
|
targetPages: string[];
|
||||||
|
}>;
|
||||||
sourceSeeds: Array<{
|
sourceSeeds: Array<{
|
||||||
phrase: string;
|
phrase: string;
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
|
|
@ -100,6 +112,7 @@ export type SeoNormalizationModelTaskContract = {
|
||||||
| "normalize_market_queries"
|
| "normalize_market_queries"
|
||||||
| "reject_noise"
|
| "reject_noise"
|
||||||
| "mark_human_review"
|
| "mark_human_review"
|
||||||
|
| "expand_vertical_queries"
|
||||||
| "route_wordstat"
|
| "route_wordstat"
|
||||||
| "route_serp"
|
| "route_serp"
|
||||||
>;
|
>;
|
||||||
|
|
@ -207,6 +220,9 @@ export type SeoNormalizationContract = {
|
||||||
|
|
||||||
const GENERIC_SINGLE_WORDS = new Set([
|
const GENERIC_SINGLE_WORDS = new Set([
|
||||||
"ai",
|
"ai",
|
||||||
|
"ии",
|
||||||
|
"искусственный интеллект",
|
||||||
|
"engine",
|
||||||
"bpm",
|
"bpm",
|
||||||
"workflow",
|
"workflow",
|
||||||
"автоматизация",
|
"автоматизация",
|
||||||
|
|
@ -317,12 +333,13 @@ function normalizeSeoNormalizationOutput(
|
||||||
throw new Error("SEO normalization output должен иметь intentGroups, seedStrategy и rejectedSeeds.");
|
throw new Error("SEO normalization output должен иметь intentGroups, seedStrategy и rejectedSeeds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordstatApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToWordstat).length;
|
const seedStrategy = output.seedStrategy.map(normalizeModelSeedReview);
|
||||||
const serpApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToSerp).length;
|
const wordstatApprovedCount = seedStrategy.filter((seed) => seed.review.sendToWordstat).length;
|
||||||
const needsHumanReviewCount = output.seedStrategy.filter((seed) => seed.review.status === "needs_human").length;
|
const serpApprovedCount = seedStrategy.filter((seed) => seed.review.sendToSerp).length;
|
||||||
const autoApprovedCount = output.seedStrategy.filter((seed) => seed.review.status === "auto_approved").length;
|
const needsHumanReviewCount = seedStrategy.filter((seed) => seed.review.status === "needs_human").length;
|
||||||
|
const autoApprovedCount = seedStrategy.filter((seed) => seed.review.status === "auto_approved").length;
|
||||||
const rejectedSeedCount =
|
const rejectedSeedCount =
|
||||||
output.rejectedSeeds.length + output.seedStrategy.filter((seed) => seed.review.status === "rejected").length;
|
output.rejectedSeeds.length + seedStrategy.filter((seed) => seed.review.status === "rejected").length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...output,
|
...output,
|
||||||
|
|
@ -339,13 +356,45 @@ function normalizeSeoNormalizationOutput(
|
||||||
autoApprovedCount,
|
autoApprovedCount,
|
||||||
intentGroupCount: output.intentGroups.length,
|
intentGroupCount: output.intentGroups.length,
|
||||||
needsHumanReviewCount,
|
needsHumanReviewCount,
|
||||||
normalizedSeedCount: output.seedStrategy.length,
|
normalizedSeedCount: seedStrategy.length,
|
||||||
rejectedSeedCount,
|
rejectedSeedCount,
|
||||||
serpApprovedCount,
|
serpApprovedCount,
|
||||||
wordstatApprovedCount,
|
wordstatApprovedCount,
|
||||||
wordstatQueryCount: wordstatApprovedCount
|
wordstatQueryCount: wordstatApprovedCount
|
||||||
},
|
},
|
||||||
state: output.seedStrategy.length > 0 ? "ready" : "not_ready"
|
seedStrategy,
|
||||||
|
state: seedStrategy.length > 0 ? "ready" : "not_ready"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceGroundedVerticalSeed(seed: SeoNormalizationSeed) {
|
||||||
|
return (
|
||||||
|
seed.source === "detected" &&
|
||||||
|
seed.marketRole !== "exclude" &&
|
||||||
|
!isLowValuePhrase(seed.phrase) &&
|
||||||
|
!isBroadSinglePhrase(seed.phrase) &&
|
||||||
|
wordCount(seed.phrase) >= 2 &&
|
||||||
|
seed.confidence >= 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelSeedReview(seed: SeoNormalizationSeed): SeoNormalizationSeed {
|
||||||
|
if (seed.review.status !== "rejected" || !isSourceGroundedVerticalSeed(seed)) {
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...seed,
|
||||||
|
needsHumanReview: true,
|
||||||
|
review: {
|
||||||
|
...seed.review,
|
||||||
|
blockers: Array.from(new Set([...seed.review.blockers, "source_grounded_vertical_review"])),
|
||||||
|
reason:
|
||||||
|
`${seed.review.reason || "Model rejected source-grounded vertical."} Backend guard перевёл source-grounded vertical в needs_human: модель не должна молча выбрасывать потенциальную вертикаль сайта.`,
|
||||||
|
sendToSerp: false,
|
||||||
|
sendToWordstat: false,
|
||||||
|
status: "needs_human"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -503,8 +552,171 @@ function collectClusterPhrases(
|
||||||
...(contextHint?.sourcePhrases ?? []),
|
...(contextHint?.sourcePhrases ?? []),
|
||||||
...(contextHint?.marketAngles ?? []).filter((angle) => wordCount(angle) <= 6)
|
...(contextHint?.marketAngles ?? []).filter((angle) => wordCount(angle) <= 6)
|
||||||
];
|
];
|
||||||
|
const expansionPhrases = buildClusterExpansionPhrases(cluster, contextHint);
|
||||||
|
|
||||||
return unique([...seeds.map((seed) => seed.phrase), ...cluster.queryExamples, ...briefPhrases, ...safeHintPhrases]);
|
return unique([
|
||||||
|
...seeds.map((seed) => seed.phrase),
|
||||||
|
...cluster.queryExamples,
|
||||||
|
...briefPhrases,
|
||||||
|
...safeHintPhrases,
|
||||||
|
...expansionPhrases
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKET_EXPANSION_STOP_PARTS = new Set([
|
||||||
|
"ai",
|
||||||
|
"bpm",
|
||||||
|
"crm",
|
||||||
|
"erp",
|
||||||
|
"hub",
|
||||||
|
"ops",
|
||||||
|
"api",
|
||||||
|
"mcp",
|
||||||
|
"3d",
|
||||||
|
"demo",
|
||||||
|
"pilot",
|
||||||
|
"workspace",
|
||||||
|
"on-prem",
|
||||||
|
"private cloud",
|
||||||
|
"данных",
|
||||||
|
"данные",
|
||||||
|
"доступы",
|
||||||
|
"интеграции",
|
||||||
|
"модули",
|
||||||
|
"платформа",
|
||||||
|
"процессы",
|
||||||
|
"система"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ALLOWED_MARKET_EXPANSION_LATIN_TOKENS = new Set(["1c", "ai", "api", "bim", "bpm", "crm", "erp", "iot", "workflow"]);
|
||||||
|
|
||||||
|
const MARKET_EXPANSION_SYNONYMS: Array<[RegExp, string[]]> = [
|
||||||
|
[/digital\s+twin|цифров\w*\s+двойн/i, ["цифровой двойник", "цифровые двойники", "digital twin"]],
|
||||||
|
[/\bbim\b|(^|\s)бим(\s|$)/i, ["bim", "бим"]],
|
||||||
|
[/1c|1с/i, ["1с", "1c"]],
|
||||||
|
[/закуп/i, ["закупки", "автоматизация закупок", "управление закупками"]],
|
||||||
|
[/тендер/i, ["тендеры", "тендерный помощник", "автоматизация тендеров"]],
|
||||||
|
[/юрид/i, ["юридические процессы", "юридический ассистент", "автоматизация договоров"]],
|
||||||
|
[/беспилот/i, ["беспилотная техника", "управление беспилотниками", "платформа для беспилотников"]],
|
||||||
|
[/\biot\b|интернет\s+вещ/i, ["iot", "интернет вещей", "iot платформа"]],
|
||||||
|
[/телеметр/i, ["телеметрия", "система телеметрии", "платформа телеметрии"]],
|
||||||
|
[/строител/i, ["строительство", "автоматизация строительства", "строительный контроль"]],
|
||||||
|
[/эксплуатац/i, ["эксплуатация объектов", "управление эксплуатацией", "автоматизация эксплуатации"]]
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARKET_EXPANSION_QUERY_BLOCKLIST = [
|
||||||
|
/(^|\s)(базов[а-яёa-z0-9-]*|трендов[а-яёa-z0-9-]*|коммерческ[а-яёa-z0-9-]*)\s+интент/i,
|
||||||
|
/(^|\s)интент(:|\s|$)/i,
|
||||||
|
/(^|\s)(задач|проверок)\.$/i,
|
||||||
|
/(^|\s)(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)\s+(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)$/i,
|
||||||
|
/^(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)\s+(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)(\s|$)/i,
|
||||||
|
/[.!?:]/,
|
||||||
|
/\b[a-z]+\s+[a-z]+\s+[a-z]+\b/i
|
||||||
|
];
|
||||||
|
|
||||||
|
function splitMarketExpansionParts(value: string) {
|
||||||
|
return normalizePhrase(value)
|
||||||
|
.replace(/[()]/g, " ")
|
||||||
|
.replace(/\s+(?:и|and)\s+/gi, ",")
|
||||||
|
.split(/\s*(?:,|\/|&|\+|;)\s*/i)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => wordCount(part) >= 2 || /^[a-z0-9-]{2,16}$/i.test(part))
|
||||||
|
.filter((part) => !MARKET_EXPANSION_STOP_PARTS.has(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarketExpansionSynonyms(value: string) {
|
||||||
|
return MARKET_EXPANSION_SYNONYMS.flatMap(([pattern, replacements]) => (pattern.test(value) ? replacements : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSpecificMarketExpansionFacet(phrase: string) {
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
return /бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|1с|crm|erp|bpm|workflow|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн/i.test(
|
||||||
|
normalizedPhrase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnsafeMarketExpansionLatinToken(phrase: string) {
|
||||||
|
return phrase
|
||||||
|
.split(/\s+/)
|
||||||
|
.flatMap((token) => token.match(/[a-z0-9-]+/gi) ?? [])
|
||||||
|
.some((token) => !ALLOWED_MARKET_EXPANSION_LATIN_TOKENS.has(token.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPhraseExpansionVariants(phrase: string, clusterText: string) {
|
||||||
|
const variants = new Set<string>();
|
||||||
|
const cleanPhrase = normalizePhrase(phrase);
|
||||||
|
const hasAiContext = /\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(clusterText);
|
||||||
|
|
||||||
|
if (!cleanPhrase || MARKET_EXPANSION_STOP_PARTS.has(cleanPhrase)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
variants.add(cleanPhrase);
|
||||||
|
|
||||||
|
if (!/платформ/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`платформа ${cleanPhrase}`);
|
||||||
|
variants.add(`${cleanPhrase} платформа`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/систем/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`система ${cleanPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/автоматизац/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`автоматизация ${cleanPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAiContext && !/\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
|
||||||
|
variants.add(`${cleanPhrase} с искусственным интеллектом`);
|
||||||
|
variants.add(`ai ${cleanPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...variants].filter(isSafeWordstatExpansionPhrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeWordstatExpansionPhrase(phrase: string) {
|
||||||
|
const normalizedPhrase = normalizePhrase(phrase);
|
||||||
|
|
||||||
|
return (
|
||||||
|
wordCount(normalizedPhrase) >= 2 &&
|
||||||
|
wordCount(normalizedPhrase) <= 7 &&
|
||||||
|
/[а-яё]/i.test(normalizedPhrase) &&
|
||||||
|
normalizedPhrase !== "искусственный интеллект" &&
|
||||||
|
!/[{}[\]<>#$%^*=~`]/.test(normalizedPhrase) &&
|
||||||
|
!MARKET_EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) &&
|
||||||
|
!hasUnsafeMarketExpansionLatinToken(normalizedPhrase) &&
|
||||||
|
(!/\b(ai|ии)\b|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) ||
|
||||||
|
hasSpecificMarketExpansionFacet(normalizedPhrase)) &&
|
||||||
|
!/(^|\s)(система|платформа)\s+(система|платформа)(\s|$)/i.test(normalizedPhrase) &&
|
||||||
|
!/искусственн[а-яёa-z0-9-]*\s+интеллект.*искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) &&
|
||||||
|
!normalizedPhrase.split(/\s+/).some((token) => /^[a-z0-9-]{1,2}$/i.test(token) && !/^(ai|bim)$/i.test(token))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterExpansionPhrases(
|
||||||
|
cluster: SemanticAnalysisRun["clusters"][number],
|
||||||
|
contextHint: SeoContextReviewRun["normalizationHints"][number] | undefined
|
||||||
|
) {
|
||||||
|
const clusterText = [
|
||||||
|
cluster.title,
|
||||||
|
cluster.targetIntent,
|
||||||
|
...cluster.matchedTerms,
|
||||||
|
...cluster.queryExamples,
|
||||||
|
...(contextHint?.sourcePhrases ?? []),
|
||||||
|
...(contextHint?.marketAngles ?? []),
|
||||||
|
...cluster.evidenceSnippets.slice(0, 8).flatMap((snippet) => [snippet.title, snippet.text]),
|
||||||
|
...cluster.sourceRefs.map((ref) => ref.title)
|
||||||
|
].join(" ");
|
||||||
|
const baseParts = unique([
|
||||||
|
...splitMarketExpansionParts(cluster.title),
|
||||||
|
...splitMarketExpansionParts(cluster.targetIntent),
|
||||||
|
...cluster.matchedTerms.flatMap(splitMarketExpansionParts),
|
||||||
|
...(contextHint?.marketAngles ?? []).flatMap(splitMarketExpansionParts),
|
||||||
|
...getMarketExpansionSynonyms(clusterText)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return unique(baseParts.flatMap((part) => buildPhraseExpansionVariants(part, clusterText))).slice(0, 18);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGroupQueries(phrases: string[], clusterTitle: string, siteTopTerms: string[], stopPhrases: string[]) {
|
function buildGroupQueries(phrases: string[], clusterTitle: string, siteTopTerms: string[], stopPhrases: string[]) {
|
||||||
|
|
@ -685,6 +897,21 @@ function buildModelTaskContract(
|
||||||
targetIntent: cluster.targetIntent,
|
targetIntent: cluster.targetIntent,
|
||||||
title: cluster.title
|
title: cluster.title
|
||||||
})),
|
})),
|
||||||
|
verticalOpportunities: analysis.clusters
|
||||||
|
.filter((cluster) => cluster.status !== "missing" || cluster.priority === "high")
|
||||||
|
.slice(0, 24)
|
||||||
|
.map((cluster) => ({
|
||||||
|
id: cluster.id,
|
||||||
|
matchedTerms: cluster.matchedTerms.slice(0, 16),
|
||||||
|
priority: cluster.priority,
|
||||||
|
queryExamples: cluster.queryExamples.slice(0, 12),
|
||||||
|
sourceTitles: cluster.sourceRefs.slice(0, 8).map((ref) => ref.title),
|
||||||
|
sourceSnippets: cluster.evidenceSnippets.slice(0, 8).map((snippet) => snippet.text),
|
||||||
|
status: cluster.status,
|
||||||
|
targetIntent: cluster.targetIntent,
|
||||||
|
targetPages: cluster.targetPages.slice(0, 8),
|
||||||
|
title: cluster.title
|
||||||
|
})),
|
||||||
sourceSeeds: analysis.seedCandidates.slice(0, 80).map((seed) => ({
|
sourceSeeds: analysis.seedCandidates.slice(0, 80).map((seed) => ({
|
||||||
clusterId: seed.clusterId,
|
clusterId: seed.clusterId,
|
||||||
phrase: seed.phrase,
|
phrase: seed.phrase,
|
||||||
|
|
@ -703,6 +930,7 @@ function buildModelTaskContract(
|
||||||
"normalize_market_queries",
|
"normalize_market_queries",
|
||||||
"reject_noise",
|
"reject_noise",
|
||||||
"mark_human_review",
|
"mark_human_review",
|
||||||
|
"expand_vertical_queries",
|
||||||
"route_wordstat",
|
"route_wordstat",
|
||||||
"route_serp"
|
"route_serp"
|
||||||
],
|
],
|
||||||
|
|
@ -728,6 +956,8 @@ function buildModelTaskContract(
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.",
|
"Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.",
|
||||||
"Фраза выглядит как соседний рынок или business expansion без явного user approval.",
|
"Фраза выглядит как соседний рынок или business expansion без явного user approval.",
|
||||||
|
"Не отбрасывать source-grounded vertical opportunity: если точной рыночной формулировки нет, вернуть needs_human seed или expansion query, а не rejected.",
|
||||||
|
"Для каждой видимой продуктовой/отраслевой вертикали предложить 2-6 Wordstat-safe аналогов: коротких, рыночных, без внутренних labels.",
|
||||||
"Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review."
|
"Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review."
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue