NODEDC_SEO/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts

834 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { env } from "../config/env.js";
import { pool } from "../db/client.js";
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
export type ExternalServiceId =
| "seo_analytics_model"
| "yandex_cloud"
| "yandex_metrica"
| "yandex_search_serp"
| "yandex_webmaster"
| "yandex_wordstat";
type ExternalServiceStatus = "configured" | "disabled" | "missing_required" | "not_implemented";
type ExternalServiceFieldType = "number" | "password" | "text" | "url";
type ExternalServiceField = {
id: string;
label: string;
type: ExternalServiceFieldType;
required: boolean;
placeholder: string;
help: string;
};
type ExternalServiceDefinition = {
id: ExternalServiceId;
label: string;
description: string;
implementationStatus: "active" | "planned";
defaultMode: string;
modes: Array<{
id: string;
label: string;
}>;
docs: Array<{
label: string;
url: string;
}>;
publicFields: ExternalServiceField[];
secretFields: ExternalServiceField[];
};
type ExternalServiceSettingsRow = {
service_id: ExternalServiceId;
enabled: boolean;
mode: string;
public_config: Record<string, unknown>;
secret_config: Record<string, unknown>;
created_at: Date;
updated_at: Date;
};
export type ExternalServiceSettings = {
schemaVersion: "external-service-settings.v1";
services: Array<{
id: ExternalServiceId;
label: string;
description: string;
implementationStatus: ExternalServiceDefinition["implementationStatus"];
status: ExternalServiceStatus;
enabled: boolean;
mode: string;
modes: ExternalServiceDefinition["modes"];
dependsOn: ExternalServiceId[];
docs: ExternalServiceDefinition["docs"];
publicFields: ExternalServiceField[];
secretFields: ExternalServiceField[];
publicConfig: Record<string, string>;
secrets: Record<
string,
{
configured: boolean;
}
>;
missingFields: string[];
updatedAt: string | null;
}>;
};
export type UpdateExternalServiceSettingsInput = {
enabled: boolean;
mode: string;
publicConfig?: Record<string, unknown>;
secrets?: Record<string, unknown>;
};
export type WordstatRuntimeConfig =
| {
provider: "disabled";
mode: "disabled";
}
| {
provider: "mcp_kv";
mode: "mcp_kv";
mcpEndpoint: string;
maxSeedsPerRun: number;
}
| {
provider: "yandex_api";
mode: "yandex_search_api";
apiBaseUrl: string;
apiToken: string;
authType: "api_key" | "iam_token";
folderId: string;
regionIds: string[];
devices: string[];
numPhrases: number;
maxSeedsPerRun: number;
};
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat";
const WEB_SEARCH_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/web/search";
const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4";
const METRICA_BASE_URL = "https://api-metrika.yandex.net";
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
seo_analytics_model: {
id: "seo_analytics_model",
label: "Аналитическая модель",
description:
"Model-provider для SEO task contracts через AI Workspace surface seo-mode. Сейчас доступен Codex bridge без доступа к Yandex credentials, исходникам и apply/write actions.",
implementationStatus: "active",
defaultMode: "local_codex",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "local_codex", label: "Codex bridge" }
],
docs: [],
publicFields: [
{
id: "executorId",
label: "AI Workspace executor",
type: "text",
required: false,
placeholder: "generated after setup command",
help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback."
}
],
secretFields: []
},
yandex_cloud: {
id: "yandex_cloud",
label: "Yandex AI Studio / Cloud API key",
description:
"Один общий folderId и API key для Yandex AI Studio/Yandex Cloud: Search API, Wordstat, WebSearch и будущий model layer используют этот credential по ролям service account.",
implementationStatus: "active",
defaultMode: "api_key",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "api_key", label: "API key" },
{ id: "iam_token", label: "IAM token" }
],
docs: [
{
label: "AI Studio Search API quickstart",
url: "https://yandex.cloud/en/docs/search-api/quickstart"
},
{
label: "API keys",
url: "https://yandex.cloud/en/docs/iam/concepts/authorization/api-key"
},
{
label: "Folder ID",
url: "https://yandex.cloud/en/docs/resource-manager/operations/folder/get-id"
}
],
publicFields: [
{
id: "folderId",
label: "Yandex AI Studio folderId",
type: "text",
required: true,
placeholder: "b1g...",
help: "ID каталога Yandex Cloud. Его используют Wordstat, SERP/WebSearch и будущие AI Studio вызовы."
}
],
secretFields: [
{
id: "apiKey",
label: "Yandex AI Studio API key",
type: "password",
required: false,
placeholder: "AQVN...",
help: "API key сервисного аккаунта. Доступ к Search API/Wordstat/WebSearch определяется ролями и scope этого ключа."
},
{
id: "iamToken",
label: "IAM token",
type: "password",
required: false,
placeholder: "Bearer token",
help: "Альтернатива API key. Для prototype можно хранить локально, позже уйдёт в vault."
}
]
},
yandex_wordstat: {
id: "yandex_wordstat",
label: "Yandex Wordstat",
description: "Частотность, похожие запросы, динамика и региональная раскладка через Yandex Search API Wordstat.",
implementationStatus: "active",
defaultMode: "yandex_search_api",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "yandex_search_api", label: "Yandex Search API Wordstat" },
{ id: "mcp_kv", label: "MCP/adapter endpoint" }
],
docs: [
{
label: "Wordstat GetTop",
url: "https://aistudio.yandex.ru/docs/en/search-api/api-ref/Wordstat/getTop"
},
{
label: "Regions",
url: "https://aistudio.yandex.ru/docs/en/search-api/api-ref/Wordstat/getRegionsDistribution"
},
{
label: "Dynamics",
url: "https://aistudio.yandex.ru/docs/ru/search-api/api-ref/Wordstat/getDynamics"
}
],
publicFields: [
{
id: "apiBaseUrl",
label: "API base URL",
type: "url",
required: true,
placeholder: WORDSTAT_BASE_URL,
help: "Root endpoint Wordstat v2. Методы вызываются как /topRequests, /regions, /dynamics."
},
{
id: "regionIds",
label: "Region IDs",
type: "text",
required: false,
placeholder: "225",
help: "Через запятую. 225 — Россия; можно заменить на нужные регионы после RegionsTree."
},
{
id: "devices",
label: "Devices",
type: "text",
required: false,
placeholder: "DEVICE_ALL",
help: "Через запятую. Для первого слоя держим DEVICE_ALL."
},
{
id: "numPhrases",
label: "Phrases per seed",
type: "number",
required: false,
placeholder: "50",
help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
},
{
id: "maxSeedsPerRun",
label: "Max seeds per run",
type: "number",
required: false,
placeholder: "40",
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
},
{
id: "mcpEndpoint",
label: "MCP endpoint",
type: "url",
required: false,
placeholder: "http://localhost:4120/wordstat/collect",
help: "Используется только в режиме MCP/adapter endpoint."
}
],
secretFields: []
},
yandex_search_serp: {
id: "yandex_search_serp",
label: "Yandex SERP",
description: "Поисковая выдача, конкуренты, сниппеты и типы страниц через Yandex Search API WebSearch.",
implementationStatus: "planned",
defaultMode: "yandex_search_api",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "yandex_search_api", label: "Yandex Search API WebSearch" }
],
docs: [
{
label: "WebSearch.Search",
url: "https://aistudio.yandex.ru/docs/en/search-api/api-ref/WebSearch/search"
}
],
publicFields: [
{
id: "apiBaseUrl",
label: "API endpoint",
type: "url",
required: true,
placeholder: WEB_SEARCH_BASE_URL,
help: "REST endpoint для WebSearch.Search."
},
{
id: "searchType",
label: "Search type",
type: "text",
required: false,
placeholder: "SEARCH_TYPE_RU",
help: "Для российского рынка держим SEARCH_TYPE_RU."
}
],
secretFields: []
},
yandex_webmaster: {
id: "yandex_webmaster",
label: "Yandex Webmaster",
description: "Индексация, ошибки обхода, sitemap, canonical и статусы страниц после Apply.",
implementationStatus: "planned",
defaultMode: "oauth",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "oauth", label: "OAuth token" }
],
docs: [
{
label: "Webmaster API",
url: "https://yandex.ru/dev/webmaster/doc/en/"
}
],
publicFields: [
{
id: "apiBaseUrl",
label: "API base URL",
type: "url",
required: true,
placeholder: WEBMASTER_BASE_URL,
help: "Базовый URL Webmaster API."
},
{
id: "hostId",
label: "Host ID",
type: "text",
required: false,
placeholder: "https:example.com:443",
help: "ID сайта в Webmaster, можно заполнить позже после discovery."
}
],
secretFields: [
{
id: "oauthToken",
label: "OAuth token",
type: "password",
required: true,
placeholder: "OAuth ...",
help: "Токен доступа Webmaster API."
}
]
},
yandex_metrica: {
id: "yandex_metrica",
label: "Yandex Metrica",
description: "Фактические показы, клики, CTR, цели и обратная связь после SEO-правок.",
implementationStatus: "planned",
defaultMode: "oauth",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "oauth", label: "OAuth token" }
],
docs: [
{
label: "Metrica API",
url: "https://yandex.ru/dev/metrika/doc/api2/concept/about.html"
}
],
publicFields: [
{
id: "apiBaseUrl",
label: "API base URL",
type: "url",
required: true,
placeholder: METRICA_BASE_URL,
help: "Базовый URL Metrica API."
},
{
id: "counterId",
label: "Counter ID",
type: "text",
required: true,
placeholder: "12345678",
help: "ID счётчика Метрики."
}
],
secretFields: [
{
id: "oauthToken",
label: "OAuth token",
type: "password",
required: true,
placeholder: "OAuth ...",
help: "Токен Metrica API."
}
]
}
};
function toStringRecord(value: Record<string, unknown> | null | undefined) {
const output: Record<string, string> = {};
for (const [key, item] of Object.entries(value ?? {})) {
if (item === null || item === undefined) {
continue;
}
output[key] = String(item);
}
return output;
}
function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
if (serviceId === "yandex_cloud") {
return "";
}
if (serviceId === "yandex_wordstat") {
const defaults: Record<string, string> = {
apiBaseUrl: WORDSTAT_BASE_URL,
devices: "DEVICE_ALL",
numPhrases: "50",
maxSeedsPerRun: "40",
regionIds: "225"
};
return defaults[fieldId] ?? "";
}
if (serviceId === "yandex_search_serp") {
const defaults: Record<string, string> = {
apiBaseUrl: WEB_SEARCH_BASE_URL,
searchType: "SEARCH_TYPE_RU"
};
return defaults[fieldId] ?? "";
}
if (serviceId === "yandex_webmaster") {
return fieldId === "apiBaseUrl" ? WEBMASTER_BASE_URL : "";
}
if (serviceId === "yandex_metrica") {
return fieldId === "apiBaseUrl" ? METRICA_BASE_URL : "";
}
return "";
}
function getDependsOn(serviceId: ExternalServiceId, mode: string): ExternalServiceId[] {
if ((serviceId === "yandex_wordstat" || serviceId === "yandex_search_serp") && mode === "yandex_search_api") {
return ["yandex_cloud"];
}
return [];
}
function getPublicConfig(definition: ExternalServiceDefinition, row: ExternalServiceSettingsRow | null) {
const config = toStringRecord(row?.public_config);
for (const field of definition.publicFields) {
if (!config[field.id]) {
config[field.id] = getFieldDefault(definition.id, field.id);
}
}
return config;
}
function getMissingFields(
definition: ExternalServiceDefinition,
enabled: boolean,
mode: string,
publicConfig: Record<string, string>,
secretConfig: Record<string, unknown>,
dependencyStatuses: Map<ExternalServiceId, ExternalServiceStatus> = new Map()
) {
if (!enabled || mode === "disabled") {
return [];
}
const missing: string[] = [];
if (definition.id === "seo_analytics_model") {
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
if (!aiWorkspaceStatus.configured) {
return ["aiWorkspaceProfile"];
}
return publicConfig.executorId?.trim() ? [] : ["executorId"];
}
for (const field of definition.publicFields) {
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
continue;
}
if (field.id !== "mcpEndpoint" && mode === "mcp_kv") {
continue;
}
if (field.required && !publicConfig[field.id]?.trim()) {
missing.push(field.id);
}
}
for (const dependencyId of getDependsOn(definition.id, mode)) {
if (dependencyStatuses.get(dependencyId) !== "configured") {
missing.push(dependencyId);
}
}
if (definition.id === "yandex_cloud" && (mode === "api_key" || mode === "iam_token")) {
const hasApiKey = typeof secretConfig.apiKey === "string" && secretConfig.apiKey.trim().length > 0;
const hasIamToken = typeof secretConfig.iamToken === "string" && secretConfig.iamToken.trim().length > 0;
if (mode === "api_key" && !hasApiKey) {
missing.push("apiKey");
}
if (mode === "iam_token" && !hasIamToken) {
missing.push("iamToken");
}
} else {
for (const field of definition.secretFields) {
const value = secretConfig[field.id];
if (field.required && !(typeof value === "string" && value.trim().length > 0)) {
missing.push(field.id);
}
}
}
return missing;
}
function getStatus(
definition: ExternalServiceDefinition,
enabled: boolean,
mode: string,
missingFields: string[]
): ExternalServiceStatus {
if (!enabled || mode === "disabled") {
return "disabled";
}
if (definition.implementationStatus === "planned") {
return "not_implemented";
}
return missingFields.length === 0 ? "configured" : "missing_required";
}
function mapRow(
definition: ExternalServiceDefinition,
row: ExternalServiceSettingsRow | null,
dependencyStatuses: Map<ExternalServiceId, ExternalServiceStatus> = new Map()
) {
const enabled = row?.enabled ?? false;
const mode = row?.mode ?? "disabled";
const publicConfig = getPublicConfig(definition, row);
const secretConfig = row?.secret_config ?? {};
const secrets = Object.fromEntries(
definition.secretFields.map((field) => [
field.id,
{
configured: (() => {
const value = secretConfig[field.id];
return typeof value === "string" && value.trim().length > 0;
})()
}
])
);
const missingFields = getMissingFields(definition, enabled, mode, publicConfig, secretConfig, dependencyStatuses);
return {
id: definition.id,
label: definition.label,
description: definition.description,
implementationStatus: definition.implementationStatus,
status: getStatus(definition, enabled, mode, missingFields),
enabled,
mode,
modes: definition.modes,
dependsOn: getDependsOn(definition.id, mode),
docs: definition.docs,
publicFields: definition.publicFields,
secretFields: definition.secretFields,
publicConfig,
secrets,
missingFields,
updatedAt: row?.updated_at.toISOString() ?? null
};
}
async function getSettingsRows() {
const result = await pool.query<ExternalServiceSettingsRow>(
`
select service_id, enabled, mode, public_config, secret_config, created_at, updated_at
from external_service_settings;
`
);
return new Map(result.rows.map((row) => [row.service_id, row]));
}
function getDependencyStatuses(rows: Map<ExternalServiceId, ExternalServiceSettingsRow>) {
const cloudDefinition = serviceDefinitions.yandex_cloud;
const cloudRow = rows.get("yandex_cloud") ?? null;
const cloudPublicConfig = getPublicConfig(cloudDefinition, cloudRow);
const cloudSecretConfig = cloudRow?.secret_config ?? {};
const cloudEnabled = cloudRow?.enabled ?? false;
const cloudMode = cloudRow?.mode ?? "disabled";
const cloudMissingFields = getMissingFields(
cloudDefinition,
cloudEnabled,
cloudMode,
cloudPublicConfig,
cloudSecretConfig
);
return new Map<ExternalServiceId, ExternalServiceStatus>([
["yandex_cloud", getStatus(cloudDefinition, cloudEnabled, cloudMode, cloudMissingFields)]
]);
}
export async function getExternalServiceSettings(): Promise<ExternalServiceSettings> {
const rows = await getSettingsRows();
const dependencyStatuses = getDependencyStatuses(rows);
return {
schemaVersion: "external-service-settings.v1",
services: (Object.keys(serviceDefinitions) as ExternalServiceId[]).map((serviceId) =>
mapRow(serviceDefinitions[serviceId], rows.get(serviceId) ?? null, dependencyStatuses)
)
};
}
function cleanPublicConfig(definition: ExternalServiceDefinition, value: Record<string, unknown> | undefined) {
const allowedFields = new Set(definition.publicFields.map((field) => field.id));
const output: Record<string, string> = {};
for (const [key, item] of Object.entries(value ?? {})) {
if (!allowedFields.has(key) || item === null || item === undefined) {
continue;
}
output[key] = String(item).trim();
}
return output;
}
function mergeSecrets(
definition: ExternalServiceDefinition,
currentSecrets: Record<string, unknown>,
incomingSecrets: Record<string, unknown> | undefined
) {
const allowedFields = new Set(definition.secretFields.map((field) => field.id));
const output: Record<string, string> = {};
for (const [key, item] of Object.entries(currentSecrets)) {
if (allowedFields.has(key) && typeof item === "string" && item.trim()) {
output[key] = item;
}
}
for (const [key, item] of Object.entries(incomingSecrets ?? {})) {
if (!allowedFields.has(key)) {
continue;
}
const text = String(item ?? "").trim();
if (text) {
output[key] = text;
}
}
return output;
}
export async function updateExternalServiceSettings(
serviceId: ExternalServiceId,
input: UpdateExternalServiceSettingsInput
) {
const definition = serviceDefinitions[serviceId];
const existing = (
await pool.query<ExternalServiceSettingsRow>(
`
select service_id, enabled, mode, public_config, secret_config, created_at, updated_at
from external_service_settings
where service_id = $1;
`,
[serviceId]
)
).rows[0];
const mode = definition.modes.some((item) => item.id === input.mode) ? input.mode : definition.defaultMode;
const publicConfig = cleanPublicConfig(definition, input.publicConfig);
const secretConfig = input.enabled && mode !== "disabled" ? mergeSecrets(definition, existing?.secret_config ?? {}, input.secrets) : {};
const result = await pool.query<ExternalServiceSettingsRow>(
`
insert into external_service_settings (service_id, enabled, mode, public_config, secret_config, updated_at)
values ($1, $2, $3, $4, $5, now())
on conflict (service_id) do update
set enabled = excluded.enabled,
mode = excluded.mode,
public_config = excluded.public_config,
secret_config = excluded.secret_config,
updated_at = now()
returning service_id, enabled, mode, public_config, secret_config, created_at, updated_at;
`,
[serviceId, input.enabled, mode, publicConfig, secretConfig]
);
const row = result.rows[0];
if (!row) {
throw new Error("Не удалось сохранить настройки внешнего сервиса.");
}
const rows = await getSettingsRows();
const dependencyStatuses = getDependencyStatuses(rows);
return mapRow(definition, row, dependencyStatuses);
}
export async function getSeoAnalyticsModelRuntimeConfig() {
const rows = await getSettingsRows();
const row = rows.get("seo_analytics_model") ?? null;
const definition = serviceDefinitions.seo_analytics_model;
const publicConfig = getPublicConfig(definition, row);
return {
enabled: Boolean(row?.enabled && row.mode === "local_codex"),
executorId: publicConfig.executorId?.trim() || null,
mode: row?.mode ?? "disabled"
};
}
function splitCsv(value: string | undefined, fallback: string[]) {
const values = (value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return values.length > 0 ? values : fallback;
}
function getNumber(value: string | undefined, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
}
export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig> {
const rows = await getSettingsRows();
const row = rows.get("yandex_wordstat");
const cloudRow = rows.get("yandex_cloud");
const definition = serviceDefinitions.yandex_wordstat;
const dependencyStatuses = getDependencyStatuses(rows);
const mapped = mapRow(definition, row ?? null, dependencyStatuses);
if (row?.enabled && row.mode === "mcp_kv") {
const endpoint = mapped.publicConfig.mcpEndpoint?.trim();
if (endpoint) {
return {
provider: "mcp_kv",
mode: "mcp_kv",
mcpEndpoint: endpoint,
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
};
}
}
if (row?.enabled && row.mode === "yandex_search_api" && mapped.status === "configured") {
const cloudPublicConfig = getPublicConfig(serviceDefinitions.yandex_cloud, cloudRow ?? null);
const cloudSecretConfig = cloudRow?.secret_config ?? {};
const apiKey = typeof cloudSecretConfig.apiKey === "string" ? cloudSecretConfig.apiKey.trim() : "";
const iamToken = typeof cloudSecretConfig.iamToken === "string" ? cloudSecretConfig.iamToken.trim() : "";
return {
provider: "yandex_api",
mode: "yandex_search_api",
apiBaseUrl: mapped.publicConfig.apiBaseUrl || WORDSTAT_BASE_URL,
apiToken: apiKey || iamToken,
authType: apiKey ? "api_key" : "iam_token",
folderId: cloudPublicConfig.folderId,
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
};
}
if (env.marketProviders.wordstat.provider === "mcp_kv" && env.marketProviders.wordstat.mcpEndpoint) {
return {
provider: "mcp_kv",
mode: "mcp_kv",
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint,
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
};
}
if (
env.marketProviders.wordstat.provider === "yandex_api" &&
env.marketProviders.wordstat.apiBaseUrl &&
env.marketProviders.wordstat.apiToken &&
env.marketProviders.wordstat.folderId
) {
return {
provider: "yandex_api",
mode: "yandex_search_api",
apiBaseUrl: env.marketProviders.wordstat.apiBaseUrl,
apiToken: env.marketProviders.wordstat.apiToken,
authType: env.marketProviders.wordstat.authType,
folderId: env.marketProviders.wordstat.folderId,
regionIds: env.marketProviders.wordstat.regionIds,
devices: env.marketProviders.wordstat.devices,
numPhrases: env.marketProviders.wordstat.numPhrases,
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
};
}
return {
provider: "disabled",
mode: "disabled"
};
}