yandex-direct-mcp-plus 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -11
- package/dist/app/dry-run.d.ts +2 -0
- package/dist/app/dry-run.js +46 -0
- package/dist/app/instructions.d.ts +1 -0
- package/dist/app/instructions.js +10 -0
- package/dist/app/registry.js +5 -3
- package/dist/app/server.js +12 -5
- package/dist/shared/api/client.js +6 -2
- package/dist/shared/api/dry-run.d.ts +10 -0
- package/dist/shared/api/dry-run.js +28 -0
- package/dist/shared/api/errors.d.ts +2 -2
- package/dist/shared/api/errors.js +20 -17
- package/dist/shared/api/fetch.d.ts +1 -1
- package/dist/shared/api/fetch.js +4 -6
- package/dist/shared/api/json.d.ts +1 -1
- package/dist/shared/api/json.js +4 -6
- package/dist/shared/api/parse.js +1 -3
- package/dist/shared/api/v4.js +2 -2
- package/dist/shared/config/endpoints.js +2 -5
- package/dist/shared/config/enums.d.ts +1 -1
- package/dist/shared/config/enums.js +15 -54
- package/dist/shared/config/limits.d.ts +4 -3
- package/dist/shared/config/limits.js +14 -29
- package/dist/shared/lib/campaign-type.js +2 -10
- package/dist/shared/lib/error-hints.d.ts +2 -0
- package/dist/shared/lib/error-hints.js +25 -0
- package/dist/shared/lib/fields.js +2 -12
- package/dist/shared/lib/format.d.ts +1 -0
- package/dist/shared/lib/format.js +24 -30
- package/dist/shared/lib/id.js +3 -6
- package/dist/shared/lib/money.js +2 -4
- package/dist/shared/lib/record.d.ts +1 -0
- package/dist/shared/lib/record.js +4 -0
- package/dist/shared/lib/tool.js +3 -6
- package/dist/tools/ads/handler.js +5 -16
- package/dist/tools/bid-adjustments/handler.js +4 -10
- package/dist/tools/bid-adjustments/schema.js +2 -6
- package/dist/tools/campaigns/handler.js +19 -50
- package/dist/tools/campaigns/priority-goals.js +3 -7
- package/dist/tools/campaigns/schema.d.ts +1 -1
- package/dist/tools/campaigns/schema.js +12 -27
- package/dist/tools/campaigns/tool.js +1 -1
- package/dist/tools/dictionaries/handler.js +6 -16
- package/dist/tools/keywords/handler.js +3 -5
- package/dist/tools/keywords/schema.js +3 -10
- package/dist/tools/negative-keywords/handler.js +5 -18
- package/dist/tools/negative-keywords/merge.js +5 -18
- package/dist/tools/negative-keywords/schema.js +5 -21
- package/dist/tools/negative-keywords/shared-set-link.js +4 -11
- package/dist/tools/retargeting/tool.js +1 -1
- package/dist/tools/sitelinks/handler.js +1 -3
- package/dist/tools/time-targeting/handler.js +2 -9
- package/dist/tools/time-targeting/schema.js +1 -7
- package/dist/tools/vcards/handler.d.ts +1 -2
- package/dist/tools/vcards/handler.js +1 -38
- package/dist/tools/vcards/schema.d.ts +0 -20
- package/dist/tools/vcards/schema.js +0 -24
- package/dist/tools/vcards/tool.d.ts +0 -1
- package/dist/tools/vcards/tool.js +6 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **59 инструментов**, из них 25 только читают. Кампании и стратегии, группы, объявления и модерация, ключевые фразы и ставки, минус-фразы и общие наборы, быстрые ссылки, уточнения, изображения, визитки, корректировки ставок, ретаргетинг, аудиторные и динамические цели, фиды, расписание показов, статистика, поисковые запросы, баланс и справочники.
|
|
10
10
|
- **Деньги — в рублях**, на вводе и на выводе; в микроединицы API сервер переводит сам. Поддержан агентский режим (`Client-Login`).
|
|
11
|
-
- **ID — строками** (`"1915016273214320641"`): 64-битные идентификаторы Директа не помещаются в число JavaScript и молча теряют точность.
|
|
11
|
+
- **ID — строками** (`"1915016273214320641"`): 64-битные идентификаторы Директа не помещаются в число JavaScript и молча теряют точность.
|
|
12
12
|
- **Реклама боевая.** Тестовой среды у Директа больше нет — какие инструменты тратят деньги и что удаляют необратимо, перечислено в разделе [Что меняет данные](#что-меняет-данные).
|
|
13
13
|
- **Телеметрии нет.** Сервер не отправляет никуда ничего, кроме запросов к API Яндекса.
|
|
14
14
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
- [Установка](#установка) — Claude Code, Claude Desktop, Cursor, из исходников
|
|
19
19
|
- [Токен](#токен) — как получить и какие переменные окружения нужны
|
|
20
20
|
- [Что меняет данные](#что-меняет-данные) — что тратит бюджет и что необратимо
|
|
21
|
-
- [Инструменты](
|
|
21
|
+
- [Инструменты](#инструменты) — полный список с описаниями
|
|
22
22
|
- [Разработка](#разработка) — сборка, тесты, архитектура
|
|
23
23
|
|
|
24
24
|
## Что можно делать
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
Найди код региона для Новосибирска
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Полный список — [
|
|
38
|
+
Полный список — в разделе [Инструменты](#инструменты).
|
|
39
39
|
|
|
40
40
|
## Установка
|
|
41
41
|
|
|
@@ -81,17 +81,18 @@ OAuth-токен выпускается для приложения, зарег
|
|
|
81
81
|
|------------|:-----------:|------------|
|
|
82
82
|
| `YANDEX_DIRECT_TOKEN` | да | OAuth-токен Яндекс.Директ |
|
|
83
83
|
| `YANDEX_DIRECT_LOGIN` | нет | Логин клиента для агентских токенов (заголовок `Client-Login`). Обязателен, если токен агентский |
|
|
84
|
-
| `YANDEX_DIRECT_POLYGON_CAMPAIGN_ID` | нет | Только для `npm run test:int`: ID кампании-полигона, оставленной черновиком. Сетевые тесты пишут в неё и ни во что другое; без переменной они пропускаются |
|
|
85
84
|
|
|
86
85
|
## Что меняет данные
|
|
87
86
|
|
|
88
87
|
Тестовой среды у Яндекс.Директа больше нет: песочница отключена с июля 2026, и любой вызов идёт по боевому аккаунту. Отлаживать сценарии приходится на отдельной кампании, оставленной черновиком, — показов она не даёт и потому не тратит бюджет, пока не пройдёт модерацию и не будет включена.
|
|
89
88
|
|
|
89
|
+
У каждого пишущего инструмента есть параметр `dry_run`. С `dry_run: true` вызов проверяет параметры схемой, выполняет нужные ему чтения и возвращает тела запросов, которые ушли бы в Директ, ничего не отправляя.
|
|
90
|
+
|
|
90
91
|
Граница проходит не по «чтение или запись», а по скорости, с которой действие превращается в деньги.
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
**Только читают** все `list_*`, `get_*` и справочники. Вызвать их безопасно всегда.
|
|
93
94
|
|
|
94
|
-
**Тратят бюджет или запускают
|
|
95
|
+
**Тратят бюджет или запускают показы:**
|
|
95
96
|
|
|
96
97
|
| Инструмент | Чем именно |
|
|
97
98
|
|------------|------------|
|
|
@@ -112,7 +113,9 @@ OAuth-токен выпускается для приложения, зарег
|
|
|
112
113
|
|
|
113
114
|
Остальные инструменты создают и правят объекты. Пока кампания не прошла модерацию и не включена, показов по ней нет и бюджет не расходуется.
|
|
114
115
|
|
|
115
|
-
|
|
116
|
+
**Чего API не умеет.** Смарт-баннеры и динамические объявления Директ через API не создаёт с 22 мая 2026, визитки — тоже: в WSDL методы есть, боевой API отвечает ошибкой 3500. Такие объекты заводятся в интерфейсе Директа, а дальше сервер с ними работает: кампании читает и правит, визитки читает и удаляет. Единую перфоманс-кампанию сервер пока только читает.
|
|
117
|
+
|
|
118
|
+
## Инструменты
|
|
116
119
|
|
|
117
120
|
**Кампании**
|
|
118
121
|
|
|
@@ -120,7 +123,7 @@ OAuth-токен выпускается для приложения, зарег
|
|
|
120
123
|
|------------|----------|
|
|
121
124
|
| `list_campaigns` | Список кампаний (фильтр по статусу/типу, пагинация) |
|
|
122
125
|
| `get_campaign` | Детальная информация о кампании по ID |
|
|
123
|
-
| `create_campaign` | Создать кампанию (бюджет в рублях, выбор стратегии, часовой пояс, UTM-разметка) |
|
|
126
|
+
| `create_campaign` | Создать текстово-графическую кампанию (бюджет в рублях, выбор стратегии, часовой пояс, UTM-разметка) |
|
|
124
127
|
| `update_campaign` | Обновить название/бюджет/UTM-разметку и/или статус (SUSPEND/RESUME/ARCHIVE/UNARCHIVE) |
|
|
125
128
|
| `manage_campaigns` | suspend/resume/archive/unarchive/delete для списка кампаний |
|
|
126
129
|
| `get_strategy` | Получить стратегию текстово-графической кампании |
|
|
@@ -202,7 +205,6 @@ OAuth-токен выпускается для приложения, зарег
|
|
|
202
205
|
| `get_search_queries` | Фактические поисковые запросы для подбора минус-фраз |
|
|
203
206
|
| `get_changes` | Проверить изменения кампаний, групп, объявлений и справочников |
|
|
204
207
|
| `list_vcards` | Получить виртуальные визитки |
|
|
205
|
-
| `add_vcard` | Создать виртуальную визитку |
|
|
206
208
|
| `delete_vcards` | Удалить визитки по ID |
|
|
207
209
|
| `list_businesses` | Получить профили организаций Яндекс Бизнеса |
|
|
208
210
|
| `get_account_balance` | Баланс аккаунта (Live API v4) |
|
|
@@ -221,6 +223,8 @@ npm run typecheck # tsc --noEmit
|
|
|
221
223
|
npm run lint:dead # knip
|
|
222
224
|
```
|
|
223
225
|
|
|
226
|
+
Сетевые тесты (`npm run test:int`) идут по боевому аккаунту и пишут только в кампанию-полигон, оставленную черновиком: её ID задаётся переменной `YANDEX_DIRECT_POLYGON_CAMPAIGN_ID`. Без переменной они пропускаются.
|
|
227
|
+
|
|
224
228
|
Код разложен по слоям `app → tools → shared`; инструмент — это каталог
|
|
225
229
|
`src/tools/<домен>/` с `schema.ts`, `handler.ts` и `tool.ts`. Подробности —
|
|
226
230
|
в [docs/architecture.md](docs/architecture.md).
|
|
@@ -229,7 +233,7 @@ npm run lint:dead # knip
|
|
|
229
233
|
|
|
230
234
|
Проект начат на коде [`theYahia/yandex-direct-mcp`](https://github.com/theYahia/yandex-direct-mcp) под лицензией MIT. Расширение с 20 до 48 инструментов и перевод ID на строки — работа [**Maxim (DrSeedon)**](https://github.com/DrSeedon), [PR #7](https://github.com/theYahia/yandex-direct-mcp/pull/7); в npm эта версия не публиковалась. Дальше проект развивается самостоятельно и апстрим не отслеживает.
|
|
231
235
|
|
|
232
|
-
История до отделения от апстрима (версии 3.0.0–5.0.0, включая вклад DrSeedon) — в [docs/CHANGELOG-upstream.md](docs/CHANGELOG-upstream.md); дальнейшие изменения — в [CHANGELOG.md](CHANGELOG.md).
|
|
236
|
+
История до отделения от апстрима (версии 3.0.0–5.0.0, включая вклад DrSeedon) — в [docs/CHANGELOG-upstream.md](docs/CHANGELOG-upstream.md); дальнейшие изменения — в [CHANGELOG.md](CHANGELOG.md).
|
|
233
237
|
|
|
234
238
|
## Лицензия
|
|
235
239
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Middleware предпросмотра: пишущий инструмент получает флаг dry_run и при нём отдаёт тела
|
|
2
|
+
// запросов вместо их отправки. Чтение при этом настоящее — запись строится по живым данным.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { runDryRun } from "#shared/api/dry-run";
|
|
5
|
+
import { stringifyJson } from "#shared/api/json";
|
|
6
|
+
import { PREVIEW_STRING_MAX } from "#shared/config/limits";
|
|
7
|
+
import { isRecord } from "#shared/lib/record";
|
|
8
|
+
const dryRunField = z.boolean().optional().meta({
|
|
9
|
+
description: "true — ничего не менять: проверить параметры и вернуть тела запросов, которые ушли бы в Директ. Чтение при этом выполняется."
|
|
10
|
+
});
|
|
11
|
+
function shortenStrings(value) {
|
|
12
|
+
if (typeof value === "string" && value.length > PREVIEW_STRING_MAX) {
|
|
13
|
+
return `${value.slice(0, PREVIEW_STRING_MAX)}… (обрезано, всего ${value.length} символов)`;
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return value.map(shortenStrings);
|
|
17
|
+
if (isRecord(value)) {
|
|
18
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, shortenStrings(nested)]));
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function formatPreview(requests) {
|
|
23
|
+
const bodies = requests.map(({ service, method, params }, index) => `${index + 1}. ${service}.${method}\n${stringifyJson({ method, params: shortenStrings(params) }, 2)}`);
|
|
24
|
+
return [
|
|
25
|
+
"🔍 Предпросмотр (dry_run): в Директ ничего не отправлено.",
|
|
26
|
+
"Тела запросов — в формате API: суммы в микроединицах (рубли × 1 000 000).",
|
|
27
|
+
...bodies
|
|
28
|
+
].join("\n\n");
|
|
29
|
+
}
|
|
30
|
+
export function withDryRun(tool) {
|
|
31
|
+
if (tool.annotations.readOnlyHint)
|
|
32
|
+
return tool;
|
|
33
|
+
return {
|
|
34
|
+
...tool,
|
|
35
|
+
schema: tool.schema.extend({ dry_run: dryRunField }),
|
|
36
|
+
run: async (params) => {
|
|
37
|
+
const { dry_run: dryRun, ...toolParams } = params;
|
|
38
|
+
if (!dryRun)
|
|
39
|
+
return tool.run(toolParams);
|
|
40
|
+
const { output, requests } = await runDryRun(() => tool.run(toolParams));
|
|
41
|
+
if (requests.length > 0)
|
|
42
|
+
return formatPreview(requests);
|
|
43
|
+
return `🔍 Предпросмотр (dry_run): запись не понадобилась, вызов только читал.\n\n${output}`;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Текст, который клиент кладёт в контекст агента при подключении, до первого вызова.
|
|
2
|
+
// Здесь только то, что касается нескольких инструментов сразу; частное — в их описаниях.
|
|
3
|
+
export const SERVER_INSTRUCTIONS = [
|
|
4
|
+
"Сервер работает с API Яндекс.Директа v5 в боевом аккаунте: тестовой среды у Директа нет с июля 2026, любой пишущий вызов меняет настоящие кампании.",
|
|
5
|
+
"У каждого пишущего инструмента есть dry_run: с ним вызов проверяет параметры и показывает тела запросов, ничего не меняя. Перед рискованной правкой — сначала предпросмотр.",
|
|
6
|
+
"Деньги на входе и выходе — в рублях. ID — десятичные строки: 19-значные ID Директа не помещаются в число без потери точности.",
|
|
7
|
+
"Чего Директ через API не делает, хотя в WSDL методы объявлены (ошибка 3500): не создаёт смарт-баннеры и динамические объявления (с 22.05.2026) и визитки. Такие объекты заводятся в интерфейсе Директа; дальше сервер их читает, кампании — правит.",
|
|
8
|
+
"Единую перфоманс-кампанию сервер пока только читает: создать её можно в интерфейсе Директа.",
|
|
9
|
+
"Отказ по отдельному объекту приходит строкой ❌, и вызов помечается ошибкой, но остальные объекты пакета при этом обработаны: повторять только то, что не прошло. У частых кодов ошибок в тексте есть подсказка «Что делать» — ей следовать, а не повторять вызов вслепую."
|
|
10
|
+
].join("\n");
|
package/dist/app/registry.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Реестр инструментов: единственный список, который знает про все домены сразу.
|
|
2
|
+
// Импорты явные, не глоб: glob прячет инструмент от knip и ломает типизацию списка.
|
|
3
|
+
import { withDryRun } from "#app/dry-run";
|
|
1
4
|
import { getAccountBalanceTool } from "#tools/account/tool";
|
|
2
5
|
import { addAdExtensionsTool, deleteAdExtensionsTool, listAdExtensionsTool } from "#tools/ad-extensions/tool";
|
|
3
6
|
import { createAdGroupTool, deleteAdGroupsTool, listAdGroupsTool } from "#tools/ad-groups/tool";
|
|
@@ -18,7 +21,7 @@ import { getSearchQueriesTool } from "#tools/search-queries/tool";
|
|
|
18
21
|
import { deleteSitelinksTool, listSitelinksTool, setSitelinksTool } from "#tools/sitelinks/tool";
|
|
19
22
|
import { getStatisticsTool } from "#tools/statistics/tool";
|
|
20
23
|
import { getTimeTargetingTool, setTimeTargetingTool } from "#tools/time-targeting/tool";
|
|
21
|
-
import {
|
|
24
|
+
import { deleteVcardsTool, listVcardsTool } from "#tools/vcards/tool";
|
|
22
25
|
export const tools = [
|
|
23
26
|
// Кампании и стратегии
|
|
24
27
|
listCampaignsTool,
|
|
@@ -64,7 +67,6 @@ export const tools = [
|
|
|
64
67
|
deleteAdExtensionsTool,
|
|
65
68
|
manageAdImagesTool,
|
|
66
69
|
listVcardsTool,
|
|
67
|
-
addVcardTool,
|
|
68
70
|
deleteVcardsTool,
|
|
69
71
|
// Таргетинг и корректировки
|
|
70
72
|
listAudienceTargetsTool,
|
|
@@ -89,4 +91,4 @@ export const tools = [
|
|
|
89
91
|
listFeedsTool,
|
|
90
92
|
getRegionsTool,
|
|
91
93
|
listTimeZonesTool
|
|
92
|
-
];
|
|
94
|
+
].map(withDryRun);
|
package/dist/app/server.js
CHANGED
|
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { SERVER_INSTRUCTIONS } from "#app/instructions";
|
|
7
|
+
import { hasItemErrors } from "#shared/lib/format";
|
|
6
8
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
7
9
|
export function readVersion() {
|
|
8
10
|
try {
|
|
@@ -14,17 +16,22 @@ export function readVersion() {
|
|
|
14
16
|
return "0.0.0";
|
|
15
17
|
}
|
|
16
18
|
}
|
|
19
|
+
// Отказ всего запроса хендлер бросает, и isError ставит SDK. Частичный отказ приходит
|
|
20
|
+
// обычным текстом — без признака клиент принял бы его за успех.
|
|
21
|
+
const toCallResult = (text) => ({
|
|
22
|
+
content: [{ type: "text", text }],
|
|
23
|
+
isError: hasItemErrors(text)
|
|
24
|
+
});
|
|
17
25
|
export function createServer(tools) {
|
|
18
|
-
const server = new McpServer({ name: "yd-mcp", version: readVersion() });
|
|
26
|
+
const server = new McpServer({ name: "yd-mcp", version: readVersion() }, { instructions: SERVER_INSTRUCTIONS });
|
|
19
27
|
for (const tool of tools) {
|
|
20
28
|
server.registerTool(tool.name, {
|
|
21
29
|
title: tool.title,
|
|
22
30
|
description: tool.description,
|
|
23
|
-
|
|
31
|
+
// Схема целиком, не .shape: из словаря полей SDK собрал бы новый объект без .refine.
|
|
32
|
+
inputSchema: tool.schema,
|
|
24
33
|
annotations: tool.annotations
|
|
25
|
-
}, async (params) => (
|
|
26
|
-
content: [{ type: "text", text: await tool.run(params) }]
|
|
27
|
-
}));
|
|
34
|
+
}, async (params) => toCallResult(await tool.run(params)));
|
|
28
35
|
}
|
|
29
36
|
return server;
|
|
30
37
|
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// JSON API v5 — основной клиент: один POST на сервис с телом { method, params }.
|
|
2
|
+
import { holdWrite } from "#shared/api/dry-run";
|
|
2
3
|
import { assertNoApiError } from "#shared/api/errors";
|
|
3
4
|
import { commonHeaders, fetchWithRetry, logUnits } from "#shared/api/fetch";
|
|
4
5
|
import { parseJson, stringifyJson } from "#shared/api/json";
|
|
5
6
|
import { BASE_URL } from "#shared/config/endpoints";
|
|
6
7
|
export async function apiPost(service, method, params = {}) {
|
|
8
|
+
const held = holdWrite(service, method, params);
|
|
9
|
+
if (held)
|
|
10
|
+
return held;
|
|
7
11
|
const response = await fetchWithRetry(`${BASE_URL}${service}`, {
|
|
8
12
|
method: "POST",
|
|
9
13
|
headers: commonHeaders(),
|
|
10
14
|
body: stringifyJson({ method, params })
|
|
11
15
|
});
|
|
12
|
-
logUnits(response);
|
|
16
|
+
const units = logUnits(response);
|
|
13
17
|
const data = parseJson(await response.text());
|
|
14
|
-
assertNoApiError(data);
|
|
18
|
+
assertNoApiError(data, units);
|
|
15
19
|
return data;
|
|
16
20
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type HeldRequest = {
|
|
2
|
+
service: string;
|
|
3
|
+
method: string;
|
|
4
|
+
params: Record<string, unknown>;
|
|
5
|
+
};
|
|
6
|
+
export declare function runDryRun<T>(action: () => Promise<T>): Promise<{
|
|
7
|
+
output: T;
|
|
8
|
+
requests: HeldRequest[];
|
|
9
|
+
}>;
|
|
10
|
+
export declare function holdWrite(service: string, method: string, params: Record<string, unknown>): unknown;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Предпросмотр записи: внутри runDryRun пишущий запрос не уходит в Директ, а откладывается.
|
|
2
|
+
// Контекст — AsyncLocalStorage, чтобы хендлеры не знали о режиме и не передавали флаг вниз.
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
// Всё, чего здесь нет, считается записью: незнакомый метод безопаснее придержать, чем отправить.
|
|
5
|
+
const READ_METHODS = new Set([
|
|
6
|
+
"get",
|
|
7
|
+
"getGeoRegions",
|
|
8
|
+
"check",
|
|
9
|
+
"checkCampaigns",
|
|
10
|
+
"checkDictionaries",
|
|
11
|
+
"hasSearchVolume"
|
|
12
|
+
]);
|
|
13
|
+
// Ответ-заглушка вместо настоящего: хендлер доходит до конца и не спотыкается о пустоту.
|
|
14
|
+
const HELD_RESPONSE = { result: {} };
|
|
15
|
+
const heldRequests = new AsyncLocalStorage();
|
|
16
|
+
export async function runDryRun(action) {
|
|
17
|
+
const requests = [];
|
|
18
|
+
const output = await heldRequests.run(requests, action);
|
|
19
|
+
return { output, requests };
|
|
20
|
+
}
|
|
21
|
+
// Ответ-заглушка, если запрос отложен; undefined — запрос надо отправить.
|
|
22
|
+
export function holdWrite(service, method, params) {
|
|
23
|
+
const requests = heldRequests.getStore();
|
|
24
|
+
if (!requests || READ_METHODS.has(method))
|
|
25
|
+
return undefined;
|
|
26
|
+
requests.push({ service, method, params });
|
|
27
|
+
return HELD_RESPONSE;
|
|
28
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare function assertNoApiError(data: unknown): void;
|
|
2
|
-
export declare function assertNoApiErrorV4(data: Record<string, unknown> | null): void;
|
|
1
|
+
export declare function assertNoApiError(data: unknown, units?: string): void;
|
|
2
|
+
export declare function assertNoApiErrorV4(data: Record<string, unknown> | null, units?: string): void;
|
|
3
3
|
export declare function assertNoReportAuthError(body: string): void;
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
// Разбор ошибок Директа. Хендлеры сюда не заглядывают: их дело — сценарий.
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// error_detail, а Reports — HTTP 400 и XML, где код лежит в <reports:errorCode>.
|
|
2
|
+
import { getErrorHint } from "#shared/lib/error-hints";
|
|
3
|
+
// 53 — токен истёк, отозван или неверен. v5 отдаёт её с HTTP 200, v4 — с пустым
|
|
4
|
+
// error_detail, Reports — HTTP 400 и XML. Текст заменяется целиком: из родного не понять, что делать.
|
|
6
5
|
const AUTH_ERROR_CODE = 53;
|
|
7
|
-
// Текст адресован модели на другом конце протокола, а не человеку в логе: без явного
|
|
8
|
-
// «повтор не поможет» она уводит вызов в ретраи, и пользователь так и не узнает, что
|
|
9
|
-
// нужно перевыпустить токен.
|
|
10
6
|
const AUTH_ERROR_MESSAGE = [
|
|
11
7
|
"Токен Яндекс.Директа не принят: истёк, отозван или задан неверно.",
|
|
12
8
|
"Это не сбой сети и не временная ошибка — повторять вызов бесполезно.",
|
|
@@ -16,9 +12,17 @@ const AUTH_ERROR_MESSAGE = [
|
|
|
16
12
|
function isAuthError(code) {
|
|
17
13
|
return Number(code) === AUTH_ERROR_CODE;
|
|
18
14
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
function withAdvice(message, code, units) {
|
|
16
|
+
const parts = [message];
|
|
17
|
+
const hint = getErrorHint(code);
|
|
18
|
+
if (hint)
|
|
19
|
+
parts.push(`Что делать: ${hint}`);
|
|
20
|
+
if (units)
|
|
21
|
+
parts.push(`Баллы API (потрачено/остаток/лимит): ${units}.`);
|
|
22
|
+
return parts.join(" ");
|
|
23
|
+
}
|
|
24
|
+
// v5 возвращает ошибку запроса телом с HTTP 200 — признак только ключ `error`.
|
|
25
|
+
export function assertNoApiError(data, units) {
|
|
22
26
|
const error = data?.error;
|
|
23
27
|
if (!error || typeof error !== "object")
|
|
24
28
|
return;
|
|
@@ -29,22 +33,21 @@ export function assertNoApiError(data) {
|
|
|
29
33
|
parts.push(`— ${error.error_detail}`);
|
|
30
34
|
if (error.request_id)
|
|
31
35
|
parts.push(`(request_id: ${error.request_id})`);
|
|
32
|
-
throw new Error(parts.join(" "));
|
|
36
|
+
throw new Error(withAdvice(parts.join(" "), error.error_code, units));
|
|
33
37
|
}
|
|
34
38
|
// v4 отвечает по-своему: error_str вместо error_string, признак — любой из двух ключей.
|
|
35
|
-
export function assertNoApiErrorV4(data) {
|
|
39
|
+
export function assertNoApiErrorV4(data, units) {
|
|
36
40
|
if (!data || (data.error_code === undefined && data.error_str === undefined))
|
|
37
41
|
return;
|
|
38
42
|
if (isAuthError(data.error_code))
|
|
39
43
|
throw new Error(AUTH_ERROR_MESSAGE);
|
|
40
44
|
const detail = data.error_detail ? ` — ${data.error_detail}` : "";
|
|
41
|
-
|
|
45
|
+
const message = `Ошибка API v4 [${data.error_code ?? "?"}]: ${data.error_str ?? "неизвестная ошибка"}${detail}`;
|
|
46
|
+
throw new Error(withAdvice(message, data.error_code, units));
|
|
42
47
|
}
|
|
43
48
|
const REPORT_ERROR_CODE = /<reports:errorCode>(\d+)<\/reports:errorCode>/;
|
|
44
|
-
// Reports
|
|
45
|
-
//
|
|
46
|
-
// Вызывается из fetchWithRetry — там тело неуспешного ответа и оказывается. Код тянем
|
|
47
|
-
// регуляркой: разбирать XML ради одного числа — лишняя зависимость.
|
|
49
|
+
// Reports отвечает HTTP-кодом и XML, поэтому проверка живёт в транспорте. Регулярка вместо
|
|
50
|
+
// разбора XML — ради одного числа.
|
|
48
51
|
export function assertNoReportAuthError(body) {
|
|
49
52
|
const code = REPORT_ERROR_CODE.exec(body)?.[1];
|
|
50
53
|
if (code && isAuthError(code))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare function commonHeaders(): Record<string, string>;
|
|
2
|
-
export declare function logUnits(response: Response):
|
|
2
|
+
export declare function logUnits(response: Response): string | undefined;
|
|
3
3
|
export type Transport = (url: string, options: RequestInit) => Promise<Response>;
|
|
4
4
|
export declare function setTransport(next: Transport): void;
|
|
5
5
|
export declare function fetchWithRetry(url: string, options?: RequestInit, retries?: number): Promise<Response>;
|
package/dist/shared/api/fetch.js
CHANGED
|
@@ -13,19 +13,17 @@ export function commonHeaders() {
|
|
|
13
13
|
headers["Client-Login"] = clientLogin;
|
|
14
14
|
return headers;
|
|
15
15
|
}
|
|
16
|
-
// Units
|
|
17
|
-
// там транспорт MCP, поэтому stderr.
|
|
16
|
+
// Units — «потрачено/остаток/лимит». В stderr: stdout занят транспортом MCP.
|
|
18
17
|
export function logUnits(response) {
|
|
19
|
-
const units = response.headers?.get?.("Units");
|
|
18
|
+
const units = response.headers?.get?.("Units") ?? undefined;
|
|
20
19
|
if (units)
|
|
21
20
|
console.error(`[yd-mcp] Баллы API (потрачено/остаток/лимит): ${units}`);
|
|
21
|
+
return units;
|
|
22
22
|
}
|
|
23
23
|
function delay(ms) {
|
|
24
24
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
25
|
}
|
|
26
|
-
// Шов для
|
|
27
|
-
// подмена задевала всё, что в нём живёт, включая SDK. Здесь шов свой, объявлен типом
|
|
28
|
-
// и виден в коде; сеть трогает единственная строка ниже.
|
|
26
|
+
// Шов для тестов: подмена глобального fetch задела бы и SDK.
|
|
29
27
|
let transport = (url, options) => fetch(url, options);
|
|
30
28
|
export function setTransport(next) {
|
|
31
29
|
transport = next;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare function parseJson(text: string): unknown;
|
|
2
|
-
export declare function stringifyJson(value: unknown): string;
|
|
2
|
+
export declare function stringifyJson(value: unknown, indent?: number): string;
|
package/dist/shared/api/json.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// biome-ignore-all lint/plugin: единственное место в проекте, где вызывается разбор JSON
|
|
2
|
-
// ID
|
|
3
|
-
//
|
|
4
|
-
// storeAsString отдаёт большие числа строками; сериализация принимает BigInt.
|
|
5
|
-
// Разбор и сериализация JSON живут только здесь — держит GritQL-плагин Biome.
|
|
2
|
+
// 19-значные ID родной JSON.parse молча округляет в чужой, но валидный ID; storeAsString
|
|
3
|
+
// отдаёт их строками. Разбор JSON — только здесь, держит GritQL-плагин Biome.
|
|
6
4
|
import JSONbigFactory from "json-bigint";
|
|
7
5
|
const JSONbig = JSONbigFactory({ storeAsString: true });
|
|
8
6
|
export function parseJson(text) {
|
|
9
7
|
return JSONbig.parse(text);
|
|
10
8
|
}
|
|
11
|
-
export function stringifyJson(value) {
|
|
12
|
-
return JSONbig.stringify(value);
|
|
9
|
+
export function stringifyJson(value, indent) {
|
|
10
|
+
return JSONbig.stringify(value, null, indent);
|
|
13
11
|
}
|
package/dist/shared/api/parse.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { prettifyError } from "zod";
|
|
2
|
-
// Схемы ответов
|
|
3
|
-
// предупреждения, и строгая схема превратила бы такое добавление в отказ инструмента.
|
|
4
|
-
// Обязательными объявляются только поля, на которые опирается сам инструмент.
|
|
2
|
+
// Схемы ответов — нестрогие: новое поле Директа не должно ломать инструмент.
|
|
5
3
|
export function parseApiResult(schema, value, what) {
|
|
6
4
|
const parsed = schema.safeParse(value);
|
|
7
5
|
if (parsed.success)
|
package/dist/shared/api/v4.js
CHANGED
|
@@ -11,8 +11,8 @@ export async function apiV4(method, param = {}) {
|
|
|
11
11
|
headers: { "Content-Type": "application/json", "Accept-Language": "ru" },
|
|
12
12
|
body: stringifyJson({ method, token: getToken(), param })
|
|
13
13
|
});
|
|
14
|
-
logUnits(response);
|
|
14
|
+
const units = logUnits(response);
|
|
15
15
|
const data = parseJson(await response.text());
|
|
16
|
-
assertNoApiErrorV4(data);
|
|
16
|
+
assertNoApiErrorV4(data, units);
|
|
17
17
|
return data;
|
|
18
18
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// Эндпоинты Директа.
|
|
2
|
-
// (ответ поддержки 05.09.2026), контур один — боевой. Отсюда константы, а не функции.
|
|
1
|
+
// Эндпоинты Директа. Контур один — боевой: песочница отключена Яндексом с июля 2026.
|
|
3
2
|
export const BASE_URL = "https://api.direct.yandex.com/json/v5/";
|
|
4
3
|
export const REPORT_URL = `${BASE_URL}reports`;
|
|
5
|
-
//
|
|
6
|
-
// не отсутствуют вовсе — у кампании это поле Funds, у клиента Bonuses и
|
|
7
|
-
// OverdraftSumAvailable, — но суммы на счёте среди них нет. Сверено с WSDL 05.09.2026.
|
|
4
|
+
// Только ради баланса общего счёта: в v5 его нет, есть лишь Funds кампании и бонусы клиента.
|
|
8
5
|
export const V4_URL = "https://api.direct.yandex.ru/live/v4/json/";
|
|
@@ -3,7 +3,7 @@ export declare const KEYWORD_ACTIONS: readonly ["suspend", "resume", "delete"];
|
|
|
3
3
|
export declare const AD_ACTIONS: readonly ["suspend", "resume", "archive", "unarchive", "moderate", "delete"];
|
|
4
4
|
export declare const CAMPAIGN_ACTIONS: readonly ["suspend", "resume", "archive", "unarchive", "delete"];
|
|
5
5
|
export declare const CAMPAIGN_STATUS_ACTIONS: readonly ["SUSPEND", "RESUME", "ARCHIVE", "UNARCHIVE"];
|
|
6
|
-
export declare const CAMPAIGN_TYPES_CREATABLE: readonly ["TEXT_CAMPAIGN"
|
|
6
|
+
export declare const CAMPAIGN_TYPES_CREATABLE: readonly ["TEXT_CAMPAIGN"];
|
|
7
7
|
export declare const CAMPAIGN_TYPES: readonly ["TEXT_CAMPAIGN", "MOBILE_APP_CAMPAIGN", "DYNAMIC_TEXT_CAMPAIGN", "CPM_BANNER_CAMPAIGN", "SMART_CAMPAIGN", "UNIFIED_CAMPAIGN"];
|
|
8
8
|
export declare const SEARCH_STRATEGIES: readonly ["HIGHEST_POSITION", "IMPRESSIONS_BELOW_SEARCH", "WB_MAXIMUM_CLICKS", "WB_MAXIMUM_CONVERSION_RATE", "WEEKLY_CLICK_PACKAGE", "AVERAGE_CPC", "AVERAGE_CPA", "AVERAGE_CPA_MULTIPLE_GOALS", "AVERAGE_ROI", "AVERAGE_CRR", "PAY_FOR_CONVERSION", "PAY_FOR_CONVERSION_CRR", "PAY_FOR_CONVERSION_MULTIPLE_GOALS", "MAX_PROFIT", "SERVING_OFF"];
|
|
9
9
|
export declare const NETWORK_STRATEGIES: readonly ["NETWORK_DEFAULT", "MAXIMUM_COVERAGE", "WB_MAXIMUM_CLICKS", "WB_MAXIMUM_CONVERSION_RATE", "WEEKLY_CLICK_PACKAGE", "AVERAGE_CPC", "AVERAGE_CPA", "AVERAGE_CPA_MULTIPLE_GOALS", "AVERAGE_ROI", "AVERAGE_CRR", "PAY_FOR_CONVERSION", "PAY_FOR_CONVERSION_CRR", "PAY_FOR_CONVERSION_MULTIPLE_GOALS", "MAX_PROFIT", "SERVING_OFF"];
|