834 lines
25 KiB
TypeScript
834 lines
25 KiB
TypeScript
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"
|
||
};
|
||
}
|