ozon-seller-mcp 2.2.0 → 2.3.1

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 CHANGED
@@ -37,7 +37,8 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
37
37
 
38
38
  | 变量 | 默认 | 说明 |
39
39
  |---|---|---|
40
- | `OZON_CLIENT_ID` / `OZON_API_KEY` | — | 店铺凭证,必填 |
40
+ | `OZON_CLIENT_ID` / `OZON_API_KEY` | — | 单店凭证;只有一家店时用 |
41
+ | `OZON_SHOPS` | — | 多店铺:JSON 数组,见下方「多店铺」;填了它就不再读单店的 4 个凭证变量 |
41
42
  | `OZON_READ_ONLY` | `true` | `false` 才允许写操作;其他值启动报错,拼写错误不会开放写入 |
42
43
  | `OZON_TOOLSET` | `core` | `core` 常用工具;`minimal` 只要组合工具和通用工具;`all` 全部接口;可追加接口路径或工具名,逗号分隔,如 `core,/v1/returns/list` |
43
44
  | `OZON_COMPACT` | `true` | 结果省略 null、空字符串、空数组、空对象 |
@@ -50,6 +51,32 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
50
51
 
51
52
  修改后需要重启 MCP。
52
53
 
54
+ ## 多店铺
55
+
56
+ 一个 MCP 连多家店:`OZON_SHOPS` 填一个 JSON 数组,每家店一项。
57
+
58
+ ```json
59
+ "env": {
60
+ "OZON_SHOPS": "[{\"name\":\"shop-a\",\"client_id\":\"…\",\"api_key\":\"…\",\"perf_client_id\":\"…@advertising.performance.ozon.ru\",\"perf_client_secret\":\"…\",\"market\":true,\"default\":true},{\"name\":\"shop-b\",\"client_id\":\"…\",\"api_key\":\"…\",\"perf_client_id\":\"…\",\"perf_client_secret\":\"…\"}]",
61
+ "OZON_READ_ONLY": "true"
62
+ }
63
+ ```
64
+
65
+ | 字段 | 必填 | 说明 |
66
+ |---|---|---|
67
+ | `name` | 是 | 店铺名,1~32 个字母、数字、汉字、`_`、`-`,不能重复;调用时用它选店 |
68
+ | `client_id` / `api_key` | 二选一 | 该店的 Seller API 凭证,两项要么都填要么都不填 |
69
+ | `perf_client_id` / `perf_client_secret` | 二选一 | 该店的广告凭证,两项要么都填要么都不填;每家店至少填一组凭证(只做广告的店可以不填 Seller API) |
70
+ | `access_token` | 否 | 该店的 Ozon Delivery OAuth 令牌 |
71
+ | `default` | 否 | `true` = 调用不指定店铺时用它;不标就用第一项 |
72
+ | `market` | 否 | `true` = 市场数据店,最多一家;没有店标它时,市场数据接口就用调用时选的那家店 |
73
+
74
+ - 访问 Ozon 的工具(含组合工具、广告工具)都多一个可选参数 `shop`(店铺名),不填用默认店。结果里的 `shop` 字段写明实际用哪家店的凭证查的。只在本地运行的工具(`ozon_status`、目录、说明、校验、报告)没有这个参数。
75
+ - **市场数据固定走 `market` 店**:类目大盘 `/v1/analytics/category/comparison`、全站热搜词 `/v1/search-queries/top`、`/v1/search-queries/text` 要 Premium Pro 订阅,结果和查询的店铺无关,所以不论 `shop` 填哪家,都用市场数据店的凭证查。本店相关的数据(销量、商品搜索词、价格指数、广告)一律用各自店的凭证。
76
+ - 限流和 5 分钟读缓存按店铺(Client-Id)分开计;广告令牌每家店各一个。
77
+ - 配置有错(JSON 不合法、缺凭证、重名、未知字段、两家店填了同一个 `client_id` / `perf_client_id`、两家都标 `market`)启动时直接报错,报错只说第几项哪个字段,不回显密钥。所有店的密钥都会从结果和报错里隐藏。
78
+ - `ozon_status` 列出每家店的名称、Client-Id、是否默认 / 市场店、是否配了 Seller API / 广告凭证,不含密钥。
79
+
53
80
  ## 通过节点访问 Ozon
54
81
 
55
82
  国内网络直连 Ozon 经常不通。把节点链接填进 `OZON_PROXY` 即可,不用另装代理软件:
@@ -95,6 +122,8 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
95
122
 
96
123
  \* 2026-10-26 在一家约 480 个商品的店铺实测,从国内网络访问。
97
124
 
125
+ 营收 `revenue`(来自 `/v1/analytics/data`)单位是卢布,结果里带 `revenue_currency: "RUB"`;商品价格 `price`/`old_price`/`min_price` 是店铺结算币种(可能是 CNY),见 `price_currency`(旧字段 `currency` 保留,值相同)。两者不要直接相加或相除。
126
+
98
127
  ### 通用工具
99
128
 
100
129
  | 工具 | 用途 |
@@ -131,7 +160,7 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
131
160
  | `ozon_ads_api_catalog` | 搜索全部 48 个广告接口 + 官方说明中给出的 12 个 `/json` 报表版本,或查看单个接口的参数与响应定义 |
132
161
  | `ozon_ads_api_call` | 按路径调用任一广告接口:路径参数和查询参数放 `params`,请求体放 `body` |
133
162
 
134
- - `ozon_ads_stats` 的活动汇总来自 `/api/client/statistics/campaign/product/json`(同步,不消耗限额);商品明细来自异步活动报表 `/api/client/statistics/json`(每次最多 10 个活动,同一账号同时只生成 1 个,最多等 90 秒),包含本期内已移出活动的商品。`include_products=false` 只看活动汇总
163
+ - `ozon_ads_stats` 的活动汇总来自 `/api/client/statistics/campaign/product/json`(同步,不消耗限额);商品明细来自异步活动报表 `/api/client/statistics/json`(每次最多 10 个活动,同一账号同时只生成 1 个,最多等 90 秒),包含本期内已移出活动的商品。同一进程内同一广告账号的报表逐个生成:本服务记住每个广告账号最近提交的报表(UUID、提交时间),下一次提交前(`ozon_ads_stats` 和 `ozon_ads_api_call` 的报表提交,含外部流量报表)先在本次调用内查它的状态,等到 OK/ERROR(外部流量报表还有 TIMEOUT/CANCEL)或它已提交满 120 秒;查询失败不算完成,每次查询也受上一份报表剩余的等待上限约束。本次调用的时间预算内没等到就不提交并返回提示。报表提交不自动重试;已发出但结果未知(连接中断、超时、5xx)时,按“可能正在生成”处理,下一次提交等它满 120 秒。没有任何后台任务;相同查询同时进行时共用一份报表。`ozon_ads_stats` 整次调用(活动列表、汇总、取令牌、等待、重试、报表)共用一个 90 秒期限,超时即取消,不再发出后续请求。`include_products=false` 只看活动汇总
135
164
  - `/api/client/statistics/products/sku` 实测只接受今天或昨天(Ozon 返回 `date range must contain only today or yesterday`),所以不用于多天统计
136
165
  - `OZON_READ_ONLY=true` 时,改出价、启停活动、增删商品、开关“按订单付款”等 16 个写接口在发送前拦截(包括 `GET /api/client/campaign/all_sku_promo/activate` 这类用 GET 做写操作的接口)
137
166
  - 官方限制:每天总计 100 000 次;统计报表同一账号同时只能生成 1 个、一次最多 10 个活动、最长 62 天
@@ -158,13 +187,14 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
158
187
 
159
188
  其他接口文档未写具体数字,Ozon 仍可能返回 429。
160
189
 
161
- 本地保护:同一进程内超出上述窗口时直接返回“约 N 秒后可再调用”,不发请求;限流接口的相同只读请求 5 分钟内返回缓存(`from_cache: true`)。本地计数只统计当前进程(含被 Ozon 拒绝的调用),以 Ozon 服务端为准。只读请求遇到 429/502/503/504 且 `Retry-After` ≤ 5 秒时重试一次;写请求从不自动重试。
190
+ 本地保护:同一进程内超出上述窗口时直接返回“约 N 秒后可再调用”,不发请求;限流接口的相同只读请求 5 分钟内返回缓存(`from_cache: true`)。本地计数只统计当前进程(含被 Ozon 拒绝的调用),以 Ozon 服务端为准。只读请求遇到 429/502/503/504 且 `Retry-After`(秒数或 HTTP 日期)≤ 5 秒时重试一次;每次实际发送(含重试、等过冷却之后)在发出前一刻计入上述窗口,并受每个账号的重试额度限制(并发失败时不会把请求量翻倍),等待时间加少量随机抖动。写请求从不自动重试。Ozon 返回 429 后,同一账号(同一 Client-Id 或广告 Client ID)的后续请求都先等过 `Retry-After`(最多按 60 秒计):剩余不超过 5 秒就等待后发送,更长则直接返回“约 N 秒后可再调用”;有独立限额的接口(上表)只冷却该接口本身。
162
191
 
163
192
  **实测注意**:`/v1/analytics/data` 带 `sort` 参数的请求在 2026-10-26 连续被 Ozon 以“每秒限流”拒绝,同样的请求不带 `sort` 正常。`ozon_top_products` 因此不传 `sort`,一次取 1000 个 SKU 后本地排序。
164
193
 
165
194
  ## 参数校验
166
195
 
167
- - 必填、类型、嵌套、枚举、上下限、数组约束、日期与 int32/int64/uint64 格式、未知字段,发送前全部校验
196
+ - 必填、类型、嵌套、枚举、上下限、数组约束、日期、date-time(RFC 3339,须带时区,如 `2026-10-01T00:00:00Z`;闰秒只接受 UTC 23:59:60)与 int32/int64/uint64 格式、未知字段,发送前全部校验。少数字段(如 `/v1/finance/balance` 的 `date_from`)规范类型是 date-time、说明和官方示例却是 `YYYY-MM-DD`,这些字段两种写法都接受
197
+ - 整数参数超出 ±9007199254740991 时直接拒绝:JSON 解析时这类数字已被舍入(9007199254740993 会变成 …992),照发可能作用到别的对象
168
198
  - 482 个 schema 与原 Python 版逐个比对一致(3114 个校验用例结论相同),以下 8 处除外
169
199
  - 官方规范有 8 个 schema 把某字段列为必填、却没有定义该字段(如 `/v2/products/stocks` 的 `quant_size`,`/v1/cargoes/label/transport/status` 的 `operation_idd`),原实现因此无法调用这些接口。现在这些字段改为“已声明、不校验类型”的可选字段,说明里注明官方列为必填,最终以 Ozon 返回为准
170
200
  - 官方 `type=null` 的字段禁止传入,不猜测类型;`int`/`bool` 等书写错误按无歧义方式规范化
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // MCP stdio entry. stdout carries JSON-RPC only; diagnostics go to stderr.
3
+ import { redactText } from '../src/privacy.js';
3
4
  import { VERSION } from '../src/version.js';
4
5
 
5
6
  const args = process.argv.slice(2);
@@ -11,7 +12,11 @@ if (args.includes('--version') || args.includes('-v')) {
11
12
  用法:npx -y ozon-seller-mcp
12
13
 
13
14
  环境变量:
14
- OZON_CLIENT_ID, OZON_API_KEY 店铺凭证(必填)
15
+ OZON_CLIENT_ID, OZON_API_KEY 单店凭证(只有一家店时用)
16
+ OZON_SHOPS 多店铺:JSON 数组,每项 {"name","client_id","api_key",
17
+ "perf_client_id"?,"perf_client_secret"?,"access_token"?,"market"?,"default"?};
18
+ 填了它就不再读上面的单店变量。"market": true 的店负责
19
+ 类目大盘、全站热搜词(需 Premium Pro);工具多一个 shop 参数
15
20
  OZON_READ_ONLY true(默认,只读)/ false(允许写入)
16
21
  OZON_TOOLSET core(默认)/ all / minimal / 追加接口路径,逗号分隔
17
22
  OZON_COMPACT true(默认,省略空值字段)/ false
@@ -25,14 +30,15 @@ if (args.includes('--version') || args.includes('-v')) {
25
30
  `);
26
31
  } else {
27
32
  // A stray error must not kill the server mid-conversation; log it to stderr and keep serving.
28
- const report = (kind) => (error) => process.stderr.write(`[ozon-seller-mcp] ${kind}: ${error?.stack ?? error}\n`);
33
+ // Logged text goes through the same redaction as tool results (configured keys, ad tokens).
34
+ const report = (kind) => (error) => process.stderr.write(`[ozon-seller-mcp] ${kind}: ${redactText(error)}\n`);
29
35
  process.on('unhandledRejection', report('unhandledRejection'));
30
36
  process.on('uncaughtException', report('uncaughtException'));
31
37
  try {
32
38
  const { main } = await import('../src/server.js');
33
39
  await main();
34
40
  } catch (error) {
35
- process.stderr.write(`[ozon-seller-mcp] 启动失败:${error?.message ?? error}\n`);
41
+ process.stderr.write(`[ozon-seller-mcp] 启动失败:${redactText(error?.message ?? error)}\n`);
36
42
  process.exit(1);
37
43
  }
38
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ozon-seller-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Ozon Seller API + Performance (ads) API MCP server: 482 seller endpoints, 48 advertising endpoints, one-call composite queries, read-only by default, strict schema validation.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/ads/http.js CHANGED
@@ -1,116 +1,205 @@
1
1
  // Performance API transport: client-credentials token, path/query parameters, JSON or CSV responses.
2
2
  // Shares the proxy setting and socket pool with the Seller API transport.
3
3
  import { OzonError, ValidationError } from '../errors.js';
4
- import { currentFetch, describeNetworkError, getDispatcher, parseJson, readCapped, sleep } from '../http.js';
5
- import { redact } from '../privacy.js';
4
+ import {
5
+ RETRY_STATUSES, WRITE_UNKNOWN, currentFetch, describeNetworkError, discardBody, getDispatcher, parseJson, readErrorText, readResponse,
6
+ sleep,
7
+ } from '../http.js';
8
+ import { forgetTokens, holdToken, redact, rememberToken } from '../privacy.js';
9
+ import {
10
+ DEFAULT_RETRY_MS, MAX_RETRY_WAIT_MS, admitRetry, admitSend, awaitCooldown, checkAdmission, noteRateLimited, parseRetryAfter,
11
+ withJitter,
12
+ } from '../ratelimit.js';
6
13
  import { enforceMode } from '../schema.js';
14
+ import { currentShop, hasAds, shops } from '../shops.js';
7
15
  import { VERSION } from '../version.js';
8
16
  import { parseCsv } from './csv.js';
9
17
  import { SAFE_PATH_VALUE, pathParamNames, validateAdsRequest } from './schema.js';
10
- import { ADS_BASE_URL, findAdsOperation } from './spec.js';
18
+ import { ADS_BASE_URL, findAdsOperation, isReportSubmit } from './spec.js';
11
19
 
12
20
  const HOST = new URL(ADS_BASE_URL).host;
13
21
  const TOKEN_PATH = '/api/client/token';
22
+ const TOKEN_OP = Object.freeze({ path: TOKEN_PATH });
14
23
  const USER_AGENT = `Ozon-Seller-MCP/${VERSION}`;
15
24
  const TOKEN_MARGIN_MS = 60_000;
16
25
  const DEFAULT_TOKEN_TTL_MS = 30 * 60_000;
17
26
  const MAX_BODY = 2_000_000;
18
- const RETRY_STATUSES = new Set([429, 502, 503, 504]);
27
+ // A token request serves every caller of the account, so no single caller's budget cancels it;
28
+ // it has its own limit instead.
29
+ const TOKEN_TIMEOUT_MS = 30_000;
19
30
  const SAFE_SEGMENT = new RegExp(SAFE_PATH_VALUE);
20
31
 
21
32
  export const CREDENTIAL_HELP = '在卖家后台 设置 → API 密钥 → Performance API 创建服务账号并添加密钥,'
22
33
  + '把 Client ID(以 @advertising.performance.ozon.ru 结尾)和 Client Secret 分别填到 MCP env 的 OZON_PERF_CLIENT_ID、OZON_PERF_CLIENT_SECRET,然后重启 MCP。'
23
34
  + '它和 OZON_CLIENT_ID / OZON_API_KEY(Seller API)不是同一组凭证。';
24
35
 
25
- let cachedToken = null;
26
- let pendingToken = null;
36
+ // One token per Performance client: shops alternating in one process must not evict each other's token.
37
+ const cachedTokens = new Map();
38
+ const pendingTokens = new Map();
27
39
 
28
40
  export function resetAdsToken() {
29
- cachedToken = null;
30
- pendingToken = null;
41
+ cachedTokens.clear();
42
+ pendingTokens.clear();
43
+ forgetTokens();
31
44
  }
32
45
 
33
- export const adsConfigured = () => Boolean((process.env.OZON_PERF_CLIENT_ID ?? '').trim() && (process.env.OZON_PERF_CLIENT_SECRET ?? '').trim());
46
+ // True when any shop has Performance API credentials (the ads tools are listed then).
47
+ export const adsConfigured = () => shops().some(hasAds);
34
48
 
35
49
  function adsCredentials() {
36
- const clientId = (process.env.OZON_PERF_CLIENT_ID ?? '').trim();
37
- const secret = (process.env.OZON_PERF_CLIENT_SECRET ?? '').trim();
38
- if (!clientId || !secret) throw new OzonError(`广告数据需要 Performance API 凭证,OZON_PERF_CLIENT_ID / OZON_PERF_CLIENT_SECRET 未配置。${CREDENTIAL_HELP}`);
39
- if (/[\r\n]/.test(clientId + secret)) throw new OzonError('Performance API 凭证包含无效换行,请检查 MCP 环境变量。');
40
- return { clientId, secret };
50
+ const shop = currentShop();
51
+ if (!hasAds(shop)) {
52
+ throw new OzonError(shop.legacy
53
+ ? `广告数据需要 Performance API 凭证,OZON_PERF_CLIENT_ID / OZON_PERF_CLIENT_SECRET 未配置。${CREDENTIAL_HELP}`
54
+ : `店铺「${shop.name}」没有配置广告凭证(perf_client_id / perf_client_secret)。`);
55
+ }
56
+ if (/[\r\n]/.test(shop.perfClientId + shop.perfSecret)) throw new OzonError('Performance API 凭证包含无效换行,请检查 MCP 环境变量。');
57
+ return { clientId: shop.perfClientId, secret: shop.perfSecret, shop };
41
58
  }
42
59
 
43
- // Bearer tokens are credentials too: hide the one a request used as well as the current one.
44
- function hideTokens(text, ...tokens) {
45
- return [...tokens, cachedToken?.value].filter(Boolean).reduce((out, token) => out.replaceAll(token, '[已隐藏令牌]'), String(text));
60
+ // Rate-limit scope of a Performance account: its 429 cooldown and retry budget are its own,
61
+ // separate from the Seller account of the same shop.
62
+ const accountScope = (clientId) => `perf:${clientId}`;
63
+
64
+ // The Performance client of the shop this call runs as. Not a secret (ozon_status shows it).
65
+ export const adsAccount = () => adsCredentials().clientId;
66
+
67
+ // A time limit for one call: `signal` aborts after `ms`. The timer never keeps the process alive
68
+ // on its own (unref) and done() clears it once the work has settled.
69
+ export function timeLimit(ms) {
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), ms);
72
+ timer.unref?.();
73
+ return { signal: controller.signal, deadline: Date.now() + ms, done: () => clearTimeout(timer) };
46
74
  }
47
75
 
48
- function scrubTokens(data, token) {
49
- const text = JSON.stringify(data);
50
- const hidden = hideTokens(text, token);
51
- return hidden === text ? data : JSON.parse(hidden);
76
+ const NOT_SENT = '已超过本次调用的时间预算,请求未发送。';
77
+ const notSent = () => new OzonError(NOT_SENT);
78
+
79
+ // Marks a failure of a request's own exchange, after it went out, without a definite answer from
80
+ // Ozon: a network error or cancellation during the fetch, an unreadable success body, or a 5xx. Such
81
+ // a request may have been carried out (e.g. a report may be being built). Failures before it was
82
+ // sent (budget, cooldown, the token phase) and definite refusals (3xx, 4xx) carry no mark.
83
+ const outcomeUnknown = (error) => Object.assign(error, { outcomeUnknown: true });
84
+ // send() tags failures of the fetch itself; only execute() turns that into outcomeUnknown, for the
85
+ // request it was asked to make. A token request's network error never marks a report submission.
86
+ const fetchFailed = (error) => Object.assign(error, { fetchFailed: true });
87
+
88
+ // Settles like `promise`, or rejects once `signal` aborts, whichever comes first: a request, a body
89
+ // or a wait that hangs cannot outlive its caller's time budget, whatever the fetch implementation
90
+ // does. A rejection of `promise` after the race was lost is handled here, never left unhandled.
91
+ export function untilAborted(promise, signal, message = NOT_SENT) {
92
+ const raced = Promise.resolve(promise);
93
+ if (!signal) return raced;
94
+ raced.catch(() => {});
95
+ if (signal.aborted) return Promise.reject(new OzonError(message));
96
+ return new Promise((resolve, reject) => {
97
+ const abort = () => reject(new OzonError(message));
98
+ signal.addEventListener('abort', abort, { once: true });
99
+ raced.then((value) => {
100
+ signal.removeEventListener('abort', abort);
101
+ if (signal.aborted) abort();
102
+ else resolve(value);
103
+ }, (error) => {
104
+ signal.removeEventListener('abort', abort);
105
+ reject(error);
106
+ });
107
+ });
52
108
  }
53
109
 
54
- async function send(url, init, agent, { write = false } = {}) {
110
+ // Every send waits out the account's shared 429 cooldown, then, right before it goes out, is
111
+ // dropped if the caller's budget is used up, or checked and counted by the limiter. Bearer tokens
112
+ // in a failure message are hidden by redact(), which knows every token issued in this process.
113
+ async function send(url, init, agent, { write = false, scope, op, signal } = {}) {
114
+ try {
115
+ await awaitCooldown(op, scope, signal);
116
+ } catch (error) {
117
+ throw signal?.aborted ? notSent() : error;
118
+ }
119
+ if (signal?.aborted) throw notSent();
120
+ admitSend(op, Date.now(), scope);
55
121
  const sentAt = performance.now();
56
122
  try {
57
- return await currentFetch()(url, { ...init, redirect: 'manual', dispatcher: agent });
123
+ return await untilAborted(currentFetch()(url, { ...init, redirect: 'manual', dispatcher: agent, signal }), signal);
58
124
  } catch (error) {
59
- throw new OzonError(describeNetworkError(error, performance.now() - sentAt, HOST)
60
- + (write ? ' 写入结果未知,请先查询业务状态,不要自动重试。' : ''));
125
+ if (signal?.aborted) throw fetchFailed(new OzonError(`等待 Ozon 响应超时,已取消请求。${write ? WRITE_UNKNOWN : ''}`));
126
+ throw fetchFailed(new OzonError(describeNetworkError(error, performance.now() - sentAt, HOST) + (write ? WRITE_UNKNOWN : '')));
61
127
  }
62
128
  }
63
129
 
64
- async function rejectRedirect(response, path) {
130
+ function rejectRedirect(response, path) {
65
131
  if (response.status >= 300 && response.status < 400) {
66
- await response.body?.cancel().catch(() => {});
132
+ discardBody(response);
67
133
  throw new OzonError(`HTTP ${response.status} ${path}:Ozon 返回重定向,为保护凭证未跟随。`);
68
134
  }
69
135
  }
70
136
 
71
- const errorText = async (response, token) => hideTokens(redact((await response.text().catch(() => '')).slice(0, 2000)), token);
137
+ const errorText = (response, write = false) => readErrorText(response, write, 2000);
72
138
 
73
- async function requestToken({ clientId, secret }, agent) {
139
+ async function requestToken(credentials, agent) {
140
+ const limit = timeLimit(TOKEN_TIMEOUT_MS);
141
+ try {
142
+ return await untilAborted(fetchToken(credentials, agent, limit.signal), limit.signal, 'Performance API 授权请求超时,已取消。');
143
+ } finally {
144
+ limit.done();
145
+ }
146
+ }
147
+
148
+ async function fetchToken({ clientId, secret, shop }, agent, signal) {
149
+ const scope = accountScope(clientId);
74
150
  const response = await send(ADS_BASE_URL + TOKEN_PATH, {
75
151
  method: 'POST',
76
152
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT },
77
153
  body: JSON.stringify({ client_id: clientId, client_secret: secret, grant_type: 'client_credentials' }),
78
- }, agent);
154
+ }, agent, { scope, op: TOKEN_OP, signal });
79
155
  await rejectRedirect(response, TOKEN_PATH);
156
+ if (response.status === 429) {
157
+ // The token endpoint shares the account's cooldown: every caller waits instead of asking again.
158
+ const delay = parseRetryAfter(response.headers.get('retry-after')) ?? DEFAULT_RETRY_MS;
159
+ noteRateLimited(TOKEN_OP, scope, delay);
160
+ throw new OzonError(`Performance API 授权接口限流(HTTP 429):Retry-After=${Math.ceil(delay / 1000)} 秒,请告知用户稍后再查,不要重复重试。 `
161
+ + await errorText(response));
162
+ }
80
163
  if (!response.ok) {
164
+ const fields = shop.legacy ? 'OZON_PERF_CLIENT_ID 和 OZON_PERF_CLIENT_SECRET' : `店铺「${shop.name}」的 perf_client_id 和 perf_client_secret`;
81
165
  const hint = response.status < 500
82
- ? `检查 OZON_PERF_CLIENT_ID 和 OZON_PERF_CLIENT_SECRET 是否完整、密钥是否已被删除。${CREDENTIAL_HELP}`
166
+ ? `检查 ${fields} 是否完整、密钥是否已被删除。${shop.legacy ? CREDENTIAL_HELP : ''}`
83
167
  : 'Ozon 授权服务暂时不可用,稍后再试。';
84
168
  throw new OzonError(`Performance API 授权失败(HTTP ${response.status}):${hint} ${await errorText(response)}`);
85
169
  }
86
- let data;
87
- try {
88
- data = parseJson((await readCapped(response)).toString('utf8'));
89
- } catch {
90
- throw new OzonError('Performance API 授权服务返回了无法解析的内容。');
91
- }
170
+ const data = await readResponse(response, false, (raw) => {
171
+ try {
172
+ return parseJson(raw.toString('utf8'));
173
+ } catch {
174
+ throw new OzonError('Performance API 授权服务返回了无法解析的内容。');
175
+ }
176
+ });
92
177
  if (typeof data?.access_token !== 'string' || !data.access_token) throw new OzonError('Performance API 授权响应缺少 access_token。');
93
178
  const ttl = Number(data.expires_in) > 0 ? Number(data.expires_in) * 1000 : DEFAULT_TOKEN_TTL_MS;
179
+ // Registered before first use, so no output or log line can carry it unredacted.
180
+ rememberToken(data.access_token, Date.now() + ttl);
94
181
  return { clientId, value: data.access_token, expiresAt: Date.now() + Math.max(ttl - TOKEN_MARGIN_MS, ttl / 2) };
95
182
  }
96
183
 
97
- // `rejected` is the token that just got a 401: a newer cached token is reused, otherwise a new one is fetched.
98
- async function getToken(credentials, agent, rejected) {
99
- const usable = cachedToken && cachedToken.clientId === credentials.clientId
100
- && cachedToken.value !== rejected && Date.now() < cachedToken.expiresAt;
101
- if (usable) return cachedToken.value;
102
- if (!pendingToken || pendingToken.clientId !== credentials.clientId) {
184
+ // `rejected` is the token that just got a 401: a newer cached token is reused, otherwise a new one is
185
+ // fetched. The caller waits within its own budget (`signal`); the shared request is not cancelled.
186
+ async function getToken(credentials, agent, rejected, signal) {
187
+ const { clientId } = credentials;
188
+ const cached = cachedTokens.get(clientId);
189
+ if (cached && cached.value !== rejected && Date.now() < cached.expiresAt) return cached.value;
190
+ if (signal?.aborted) throw notSent();
191
+ if (!pendingTokens.has(clientId)) {
103
192
  const promise = requestToken(credentials, agent)
104
193
  .then((token) => {
105
- cachedToken = token;
194
+ cachedTokens.set(clientId, token);
106
195
  return token.value;
107
196
  })
108
197
  .finally(() => {
109
- if (pendingToken?.promise === promise) pendingToken = null;
198
+ if (pendingTokens.get(clientId) === promise) pendingTokens.delete(clientId);
110
199
  });
111
- pendingToken = { clientId: credentials.clientId, promise };
200
+ pendingTokens.set(clientId, promise);
112
201
  }
113
- return pendingToken.promise;
202
+ return untilAborted(pendingTokens.get(clientId), signal);
114
203
  }
115
204
 
116
205
  function buildUrl(op, params) {
@@ -135,9 +224,12 @@ function buildUrl(op, params) {
135
224
 
136
225
  const ZIP_NOTE = '这是多个活动打包的 ZIP 报告,本服务不解压。请每次只传一个活动重新生成,或改用对应的 /json 接口。';
137
226
 
138
- async function readBody(response) {
227
+ function readBody(response, write) {
139
228
  const type = (response.headers.get('content-type') ?? '').toLowerCase();
140
- const raw = await readCapped(response);
229
+ return readResponse(response, write, (raw) => decodeBody(type, raw));
230
+ }
231
+
232
+ function decodeBody(type, raw) {
141
233
  if (type.includes('json')) {
142
234
  if (!raw.length) return {};
143
235
  try {
@@ -161,7 +253,7 @@ function failureHint(status, retryAfter) {
161
253
  return '检查参数是否符合官方说明(ozon_ads_api_catalog 传 endpoint 可查看)。';
162
254
  }
163
255
 
164
- async function execute(op, url, payload, credentials, agent) {
256
+ async function execute(op, url, payload, credentials, agent, signal) {
165
257
  const start = performance.now();
166
258
  const headers = { Accept: 'application/json, text/csv', 'User-Agent': USER_AGENT };
167
259
  let body;
@@ -170,44 +262,80 @@ async function execute(op, url, payload, credentials, agent) {
170
262
  if (Buffer.byteLength(body) > MAX_BODY) throw new OzonError('请求体过大,请缩小查询范围。');
171
263
  headers['Content-Type'] = 'application/json';
172
264
  }
173
- let token = await getToken(credentials, agent);
265
+ const scope = accountScope(credentials.clientId);
266
+ // A report submission is classified read-only, but repeating it could start a second report:
267
+ // like a write, it is never retried automatically.
268
+ const retryable = op.read_only && !isReportSubmit(op);
269
+ const bodyTimedOut = `读取 Ozon 响应超过本次调用的时间预算,已取消。${op.read_only ? '' : WRITE_UNKNOWN}`;
270
+ if (signal?.aborted) throw notSent();
271
+ // Fails before fetching a token when a long cooldown is on; the send itself is counted in send().
272
+ checkAdmission(op, Date.now(), scope);
273
+ let token = await getToken(credentials, agent, undefined, signal);
274
+ // The token stays listed for redaction while this request uses it, even if it is replaced meanwhile.
275
+ let release = holdToken(token);
174
276
  let refreshed = false;
175
277
  let retried = false;
176
- // At most one token refresh (a 401 means nothing was executed) and one retry for reads.
177
- for (;;) {
178
- const response = await send(url, { method: op.method, headers: { ...headers, Authorization: `Bearer ${token}` }, body }, agent,
179
- { write: !op.read_only });
180
- await rejectRedirect(response, op.path);
181
- if (response.status === 401 && !refreshed) {
182
- await response.body?.cancel().catch(() => {});
183
- refreshed = true;
184
- token = await getToken(credentials, agent, token);
185
- continue;
186
- }
187
- if (!response.ok) {
188
- const retryAfter = response.headers.get('retry-after') ?? '2';
189
- const delay = Number.isFinite(Number(retryAfter)) ? Math.max(0, Number(retryAfter)) : 2;
190
- if (op.read_only && !retried && RETRY_STATUSES.has(response.status) && delay <= 5) {
191
- await response.body?.cancel().catch(() => {});
192
- retried = true;
193
- await sleep(delay * 1000);
278
+ try {
279
+ // At most one token refresh (a 401 means nothing was executed) and one retry for reads, within
280
+ // the account's retry budget. Every send waits out the account's shared 429 cooldown.
281
+ for (;;) {
282
+ let response;
283
+ try {
284
+ response = await send(url, { method: op.method, headers: { ...headers, Authorization: `Bearer ${token}` }, body }, agent,
285
+ { write: !op.read_only, scope, op, signal });
286
+ } catch (error) {
287
+ throw error.fetchFailed ? outcomeUnknown(error) : error;
288
+ }
289
+ rejectRedirect(response, op.path);
290
+ if (response.status === 401 && !refreshed) {
291
+ discardBody(response);
292
+ refreshed = true;
293
+ release();
294
+ token = await getToken(credentials, agent, token, signal);
295
+ release = holdToken(token);
194
296
  continue;
195
297
  }
196
- throw new OzonError(`HTTP ${response.status} ${op.method} ${op.path}:${failureHint(response.status, retryAfter)} ${await errorText(response, token)}`);
298
+ if (!response.ok) {
299
+ const delay = parseRetryAfter(response.headers.get('retry-after')) ?? DEFAULT_RETRY_MS;
300
+ if (response.status === 429) noteRateLimited(op, scope, delay);
301
+ if (retryable && !retried && RETRY_STATUSES.has(response.status) && delay <= MAX_RETRY_WAIT_MS && admitRetry(op, scope)) {
302
+ discardBody(response);
303
+ retried = true;
304
+ try {
305
+ await sleep(withJitter(delay), signal);
306
+ } catch (error) {
307
+ throw signal?.aborted ? notSent() : error;
308
+ }
309
+ continue;
310
+ }
311
+ const failure = new OzonError(`HTTP ${response.status} ${op.method} ${op.path}:${failureHint(response.status, Math.ceil(delay / 1000))} `
312
+ + await untilAborted(errorText(response, !op.read_only), signal, bodyTimedOut).catch((error) => error.message));
313
+ throw response.status >= 500 ? outcomeUnknown(failure) : failure;
314
+ }
315
+ // Reading the body is part of the request: the budget covers it too.
316
+ let data;
317
+ try {
318
+ data = await untilAborted(readBody(response, !op.read_only), signal, bodyTimedOut);
319
+ } catch (error) {
320
+ throw outcomeUnknown(error);
321
+ }
322
+ return {
323
+ endpoint: op.path,
324
+ method: op.method,
325
+ http_status: response.status,
326
+ queried_at_utc: new Date().toISOString(),
327
+ elapsed_ms: Math.round(performance.now() - start),
328
+ data: redact(data),
329
+ };
197
330
  }
198
- const data = await readBody(response);
199
- return {
200
- endpoint: op.path,
201
- method: op.method,
202
- http_status: response.status,
203
- queried_at_utc: new Date().toISOString(),
204
- elapsed_ms: Math.round(performance.now() - start),
205
- data: scrubTokens(redact(data), token),
206
- };
331
+ } finally {
332
+ release();
207
333
  }
208
334
  }
209
335
 
210
- export async function adsRequest(endpoint, { method = 'GET', params = {}, body } = {}) {
336
+ // Checks everything that needs no network (mode, registry, parameters, credentials, proxy) and
337
+ // returns the request ready to send. `signal` cancels it, waits included.
338
+ export function prepareAdsRequest(endpoint, { method = 'GET', params = {}, body, signal } = {}) {
211
339
  const op = findAdsOperation(endpoint, method);
212
340
  if (!op) {
213
341
  throw new OzonError('广告接口路径或 HTTP 方法未在 Performance API 官方规范中登记,未发送请求;先用 ozon_ads_api_catalog 搜索。');
@@ -218,5 +346,10 @@ export async function adsRequest(endpoint, { method = 'GET', params = {}, body }
218
346
  const payload = validateAdsRequest(op, query, body);
219
347
  const url = buildUrl(op, query);
220
348
  const credentials = adsCredentials();
221
- return execute(op, url, payload, credentials, getDispatcher());
349
+ const agent = getDispatcher();
350
+ return (sendSignal = signal) => execute(op, url, payload, credentials, agent, sendSignal);
351
+ }
352
+
353
+ export async function adsRequest(endpoint, options = {}) {
354
+ return prepareAdsRequest(endpoint, options)();
222
355
  }