@optima-chat/tiktok-cli 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +26 -3
  2. package/dist/index.js +216 -5
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -10,15 +10,16 @@ Optima agent 用来操作 TikTok 店铺的 CLI。**薄客户端**:不直连 Ti
10
10
  tiktok <domain> <verb> --shop <shopId> [--flags]
11
11
  ```
12
12
 
13
- - `--shop` **必填**(多店显式路由,对应 A 轨「多店必须显式选店」教训)
13
+ - `--shop` **必填**(多店显式路由,对应 A 轨「多店必须显式选店」教训;`auth link` 除外)
14
14
  - 默认 **JSON 输出**,统一信封 `{ ok, data?, error?, meta? }`
15
- - `TIKTOK_BACKEND_URL` 指向 backend(默认 `http://127.0.0.1:3000`)
15
+ - `TIKTOK_BACKEND_URL` 解析顺序:显式环境变量 > `OPTIMA_ENV` 映射(`cn-stage` → `https://tiktok-api.stage.optima.chat`;`cn-prod` → `https://tiktok-api.yzsgo.com`)> 本地默认 `http://127.0.0.1:3000`
16
16
 
17
17
  ## 命令(v1 只读)
18
18
 
19
19
  | 命令 | 说明 |
20
20
  |---|---|
21
- | `shops --merchant <id>` | 查商家已授权店铺 |
21
+ | `auth link` | 生成店铺绑定授权链接(无需 --shop) |
22
+ | `shops` | 查当前登录用户已授权店铺 |
22
23
  | `orders list` | 订单列表(`--status --page-size --since`) |
23
24
  | `orders get --id <id>` | 订单详情 |
24
25
  | `analytics shop` | 店铺经营数据(`--from --to`,默认近30天) |
@@ -27,6 +28,22 @@ tiktok <domain> <verb> --shop <shopId> [--flags]
27
28
  | `promotions list` | 促销活动(`--status`) |
28
29
  | `finance statements` | 结算单 |
29
30
 
31
+ ### 建品前置只读辅助(拼商品体用)
32
+
33
+ | 命令 | 说明 |
34
+ |---|---|
35
+ | `products prerequisites` | 建品前置检查(店铺就绪状态) |
36
+ | `categories recommend --title [--desc]` | 类目推荐 |
37
+ | `categories tree [--version v2]` | 类目树 |
38
+ | `categories attributes --category <id>` | 类目属性(看 is_requried 必填项) |
39
+ | `categories rules --category <id>` | 类目规则(尺码表/认证/包装是否强制) |
40
+ | `brands list [--category] [--name]` | 品牌列表 |
41
+ | `brands recommend --title <t>` | 品牌推荐 |
42
+ | `warehouses list` | 仓库列表(跨境认 sub_type CB_*) |
43
+ | `shipping-templates list` | 运费模板列表 |
44
+ | `delivery-options --warehouse <id>` | 仓库交付方式 |
45
+ | `images upload --file <path> --use-case MAIN_IMAGE [--out <f>]` | 上传本地图 → 返回 TikTok CDN `uri`(multipart,供拼 `main_images` 用) |
46
+
30
47
  ## 写操作(Phase 2,安全护栏)
31
48
 
32
49
  写命令三道阀,把 A 轨「填完回读」纪律做进 CLI:
@@ -48,9 +65,15 @@ tiktok products set-price --shop <id> --product <pid> --sku <sid> --price 29.90
48
65
  | `products set-inventory` | 改 SKU 库存(`--product --sku --warehouse --quantity`) | ✓ 49→55→49 可逆 |
49
66
  | `promotions create-flashsale` | 建秒杀(`--title --product --sku --price [--limit --per-user --hours]`)。两步:建壳+加品 | ✓ 真实建成 |
50
67
  | `promotions deactivate` | 终止促销活动(`--id`)。不可逆 | ✓ |
68
+ | `products create --body-file <json>` | **生产级建品**(完整商品体 JSON)。dry-run 内嵌 `listing_check` 预检 → `--apply` 建品 → 回读高亮 warnings | ✓ 真实上架 V.V.Love(ACTIVATE) |
69
+ | `products add-variant --product <id> --body-file <json>` | 加规格 SKU(读现有 skus → merge → partial_edit;新 SKU 自动继承 sku_dimensions/sku_weight) | ✓ 真加变体成功 |
70
+ | `products replace-images --product <id> --body-file <json>` | 整组换主图(main_images 整组替换,非追加) | — |
71
+ | `products partial-edit --product <id> --body-file <json>` | 改商品字段(嵌套字段整组覆盖,未列子项会被清空) | — |
51
72
 
52
73
  均在跨境测试店真实验证(2026-09-02):`ok/applied` → 回读确认 → 审计落库。
53
74
 
75
+ **建品字段坑**(实测坐实,见 [`../docs/建品字段权威规格.md`](../docs/建品字段权威规格.md)):MY 跨境店价格用 `sale_price` 非 `amount`;`package_dimensions` 类目必填;仓库认 `sub_type=CB_*`;**品牌合规**——标题/主图的品牌须与 `brand_id` 一致,否则审核报「品牌不一致违规」(用第三方品牌名却不申报会被拒);`add-variant` 加的新 SKU 必须带 `sku_dimensions`/`sku_weight`(建品时平台从 package 自动回填,加变体无此回填,CLI 已自动继承)。图片经 `images upload` 传本地文件(backend multipart 路由)。
76
+
54
77
  **秒杀字段坑**(OAS 未标、实测撞出):商品级 `quantity_limit` 在 VARIATION 模式必须 `-1`(限量由 SKU 级控制);秒杀价须**低于该 SKU 近 30 天最低成交价**且**不超折扣上限**。这些复杂度被 CLI 吸收,agent 一次成型。
55
78
 
56
79
  ## 错误分类(agent 靠此决策)
package/dist/index.js CHANGED
@@ -1,10 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/commands.ts
4
+ import { readFileSync as readFileSync2 } from "fs";
5
+
3
6
  // src/client.ts
4
7
  import { readFileSync } from "fs";
5
8
  import { homedir } from "os";
9
+ import { basename } from "path";
6
10
  import { join } from "path";
7
- var BACKEND = process.env.TIKTOK_BACKEND_URL ?? "http://127.0.0.1:3000";
11
+
12
+ // src/backendUrl.ts
13
+ var ENV_URLS = {
14
+ "cn-stage": "https://tiktok-api.stage.optima.chat",
15
+ "cn-prod": "https://tiktok-api.yzsgo.com"
16
+ };
17
+ function resolveBackendUrl(env2) {
18
+ if (env2.TIKTOK_BACKEND_URL) return env2.TIKTOK_BACKEND_URL;
19
+ const mapped = env2.OPTIMA_ENV && ENV_URLS[env2.OPTIMA_ENV];
20
+ return mapped || "http://127.0.0.1:3000";
21
+ }
22
+
23
+ // src/client.ts
24
+ var BACKEND = resolveBackendUrl(process.env);
8
25
  function authHeaders() {
9
26
  const explicit = process.env.OPTIMA_TOKEN;
10
27
  if (explicit) return { Authorization: `Bearer ${explicit}` };
@@ -58,8 +75,47 @@ async function listShops() {
58
75
  return { ok: false, error: { code: "BACKEND_UNREACHABLE", message: e.message, retryable: true } };
59
76
  }
60
77
  }
78
+ async function createAuthLink() {
79
+ try {
80
+ const r = await fetch(`${BACKEND}/auth/link`, { method: "POST", headers: authHeaders() });
81
+ if (r.status === 401) return { ok: false, error: { code: "AUTH_EXPIRED", message: "\u672A\u767B\u5F55\u6216 token \u5931\u6548", retryable: false } };
82
+ const body = await r.json();
83
+ return { ok: true, data: { authorize_url: body.authorize_url, expires_in_minutes: 15 } };
84
+ } catch (e) {
85
+ return { ok: false, error: { code: "BACKEND_UNREACHABLE", message: e.message, retryable: true } };
86
+ }
87
+ }
88
+ async function uploadImage(shop, filePath, useCase) {
89
+ try {
90
+ const buf = readFileSync(filePath);
91
+ const form = new FormData();
92
+ form.append("data", new Blob([buf]), basename(filePath));
93
+ form.append("use_case", useCase);
94
+ const r = await fetch(`${BACKEND}/proxy-upload/${shop}/image`, { method: "POST", headers: authHeaders(), body: form });
95
+ const body = await r.json().catch(() => ({}));
96
+ if (body?.code === 0) return { ok: true, data: { uri: body.data?.uri, url: body.data?.url, use_case: useCase }, meta: { shop } };
97
+ return { ok: false, error: { code: r.status === 401 ? "AUTH_EXPIRED" : "TIKTOK_ERROR", message: body?.message ?? body?.error ?? `HTTP ${r.status}`, retryable: false }, meta: { shop } };
98
+ } catch (e) {
99
+ return { ok: false, error: { code: "BACKEND_UNREACHABLE", message: e.message, retryable: true } };
100
+ }
101
+ }
61
102
 
62
103
  // src/commands.ts
104
+ function readBodyFile(path) {
105
+ return JSON.parse(readFileSync2(path, "utf8"));
106
+ }
107
+ function mergeSkus(existing, add) {
108
+ const template = existing?.[0];
109
+ const filledAdd = add.map((sku) => {
110
+ if (!template) return sku;
111
+ return {
112
+ ...sku,
113
+ sku_dimensions: sku.sku_dimensions ?? template.sku_dimensions,
114
+ sku_weight: sku.sku_weight ?? template.sku_weight
115
+ };
116
+ });
117
+ return [...existing, ...filledAdd];
118
+ }
63
119
  var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
64
120
  var daysAgo = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);
65
121
  var COMMANDS = [
@@ -129,6 +185,78 @@ var COMMANDS = [
129
185
  }
130
186
  })
131
187
  },
188
+ // ===== 建品前置只读辅助命令 =====
189
+ {
190
+ name: "products prerequisites",
191
+ summary: "\u5EFA\u54C1\u524D\u7F6E\u68C0\u67E5",
192
+ run: ({ shop }) => proxy("GET", shop, "/product/202312/prerequisites", {})
193
+ },
194
+ {
195
+ name: "categories recommend",
196
+ summary: "\u7C7B\u76EE\u63A8\u8350\uFF08--title [--desc]\uFF09",
197
+ run: ({ shop, args: args2 }) => proxy("POST", shop, "/product/202309/categories/recommend", {
198
+ body: {
199
+ product_title: args2.title,
200
+ ...args2.desc ? { description: args2.desc } : {}
201
+ }
202
+ })
203
+ },
204
+ {
205
+ name: "categories tree",
206
+ summary: "\u7C7B\u76EE\u6811\uFF08--version v2\uFF09",
207
+ run: ({ shop, args: args2 }) => proxy("GET", shop, "/product/202309/categories", {
208
+ query: { category_version: args2.version ?? "v2" }
209
+ })
210
+ },
211
+ {
212
+ name: "categories attributes",
213
+ summary: "\u7C7B\u76EE\u5C5E\u6027\uFF08--category\uFF09",
214
+ run: ({ shop, args: args2 }) => proxy("GET", shop, `/product/202309/categories/${args2.category}/attributes`, {
215
+ query: { category_version: args2.version ?? "v2" }
216
+ })
217
+ },
218
+ {
219
+ name: "categories rules",
220
+ summary: "\u7C7B\u76EE\u89C4\u5219\uFF08--category\uFF09",
221
+ run: ({ shop, args: args2 }) => proxy("GET", shop, `/product/202309/categories/${args2.category}/rules`, {
222
+ query: { category_version: args2.version ?? "v2" }
223
+ })
224
+ },
225
+ {
226
+ name: "brands list",
227
+ summary: "\u54C1\u724C\u5217\u8868\uFF08[--category] [--name]\uFF09",
228
+ run: ({ shop, args: args2 }) => proxy("GET", shop, "/product/202309/brands", {
229
+ query: {
230
+ page_size: args2["page-size"] ?? "50",
231
+ ...args2.category ? { category_id: args2.category } : {},
232
+ ...args2.name ? { brand_name: args2.name } : {}
233
+ }
234
+ })
235
+ },
236
+ {
237
+ name: "brands recommend",
238
+ summary: "\u54C1\u724C\u63A8\u8350\uFF08--title\uFF09",
239
+ run: ({ shop, args: args2 }) => proxy("POST", shop, "/product/202309/brands/recommend", {
240
+ body: { product_title: args2.title }
241
+ })
242
+ },
243
+ {
244
+ name: "warehouses list",
245
+ summary: "\u4ED3\u5E93\u5217\u8868\uFF08\u8DE8\u5883\u8BA4 sub_type CB_*\uFF09",
246
+ run: ({ shop }) => proxy("GET", shop, "/logistics/202309/warehouses", {})
247
+ },
248
+ {
249
+ name: "shipping-templates list",
250
+ summary: "\u8FD0\u8D39\u6A21\u677F\u5217\u8868",
251
+ run: ({ shop }) => proxy("GET", shop, "/logistics/202510/seller_templates", {})
252
+ },
253
+ {
254
+ name: "delivery-options",
255
+ summary: "\u4ED3\u5E93\u4EA4\u4ED8\u65B9\u5F0F\uFF08--warehouse\uFF09",
256
+ run: ({ shop, args: args2 }) => proxy("GET", shop, `/logistics/202309/warehouses/${args2.warehouse}/delivery_options`, {
257
+ query: { scope: "PRODUCT" }
258
+ })
259
+ },
132
260
  // ===== 写操作(Phase 2):默认 --dry-run,--apply 才真发,成功后回读 =====
133
261
  {
134
262
  name: "products set-price",
@@ -186,6 +314,49 @@ var COMMANDS = [
186
314
  }],
187
315
  run: ({ shop, args: args2 }) => proxy("POST", shop, `/product/202309/products/${args2.product}/inventory/update`, { body: inventoryBody(args2) }),
188
316
  readback: ({ shop, args: args2 }) => proxy("GET", shop, `/product/202309/products/${args2.product}`, {})
317
+ },
318
+ {
319
+ name: "products create",
320
+ summary: "\u5EFA\u54C1\uFF08--body-file [--apply]\uFF09\u3002dry-run \u8DD1 listing_check \u9884\u68C0\uFF1B\u5199\u64CD\u4F5C",
321
+ write: true,
322
+ preview: () => [{ step: "listing_check \u9884\u68C0 \u2192 \u5EFA\u54C1", method: "POST", path: "/product/202309/products/listing_check", body: void 0 }],
323
+ precheck: ({ shop, args: args2 }) => proxy("POST", shop, "/product/202309/products/listing_check", { body: readBodyFile(args2["body-file"]) }),
324
+ run: async ({ shop, args: args2 }) => {
325
+ const body = readBodyFile(args2["body-file"]);
326
+ return proxy("POST", shop, "/product/202309/products", { body });
327
+ },
328
+ readback: async ({ shop, args: args2 }) => {
329
+ return proxy("POST", shop, "/product/202309/products/search", { query: { page_size: "5" }, body: {} });
330
+ }
331
+ },
332
+ {
333
+ name: "products add-variant",
334
+ summary: "\u7ED9\u5546\u54C1\u52A0\u89C4\u683C SKU\uFF08--product --body-file skus-delta.json [--apply]\uFF09",
335
+ write: true,
336
+ preview: () => [{ step: "\u8BFB\u73B0\u6709 skus \u2192 append \u2192 partial_edit", method: "POST", path: "/product/202309/products/{id}/partial_edit" }],
337
+ run: async ({ shop, args: args2 }) => {
338
+ const cur = await proxy("GET", shop, `/product/202309/products/${args2.product}`, {});
339
+ if (!cur.ok) return cur;
340
+ const merged = mergeSkus(cur.data?.skus ?? [], readBodyFile(args2["body-file"]).skus ?? []);
341
+ return proxy("POST", shop, `/product/202309/products/${args2.product}/partial_edit`, { body: { skus: merged } });
342
+ },
343
+ readback: ({ shop, args: args2 }) => proxy("GET", shop, `/product/202309/products/${args2.product}`, {})
344
+ },
345
+ {
346
+ name: "products replace-images",
347
+ summary: "\u6574\u7EC4\u6362\u4E3B\u56FE\uFF08--product --body-file images.json [--apply]\uFF09",
348
+ write: true,
349
+ preview: () => [{ step: "main_images \u6574\u7EC4\u66FF\u6362 \u2192 partial_edit", method: "POST", path: "/product/202309/products/{id}/partial_edit" }],
350
+ run: ({ shop, args: args2 }) => proxy("POST", shop, `/product/202309/products/${args2.product}/partial_edit`, { body: { main_images: readBodyFile(args2["body-file"]).main_images } }),
351
+ readback: ({ shop, args: args2 }) => proxy("GET", shop, `/product/202309/products/${args2.product}`, {})
352
+ },
353
+ {
354
+ name: "products partial-edit",
355
+ summary: "\u6539\u5546\u54C1\u5B57\u6BB5\uFF08--product --body-file patch.json [--apply]\uFF09\u3002\u5D4C\u5957\u5B57\u6BB5\u6574\u7EC4\u8986\u76D6",
356
+ write: true,
357
+ preview: ({ args: args2 }) => [{ step: "partial_edit\uFF08\u5D4C\u5957\u5B57\u6BB5\u672A\u5217\u5B50\u9879\u4F1A\u88AB\u6E05\u7A7A\uFF09", method: "POST", path: `/product/202309/products/${args2.product}/partial_edit`, body: readBodyFile(args2["body-file"]) }],
358
+ run: ({ shop, args: args2 }) => proxy("POST", shop, `/product/202309/products/${args2.product}/partial_edit`, { body: readBodyFile(args2["body-file"]) }),
359
+ readback: ({ shop, args: args2 }) => proxy("GET", shop, `/product/202309/products/${args2.product}`, {})
189
360
  }
190
361
  ];
191
362
  function inventoryBody(args2) {
@@ -268,7 +439,9 @@ function usage() {
268
439
  const lines = ["tiktok <domain> <verb> --shop <shopId> [--flags]", "", "\u547D\u4EE4\uFF1A"];
269
440
  for (const c of COMMANDS) lines.push(` ${c.name.padEnd(22)} ${c.summary}`);
270
441
  lines.push(` ${"shops".padEnd(22)} \u67E5\u5F53\u524D\u767B\u5F55\u7528\u6237\u5DF2\u6388\u6743\u5E97\u94FA`);
271
- lines.push("", "\u901A\u7528\uFF1A--shop \u5FC5\u586B\uFF08\u9664 shops\uFF09\uFF1B\u9ED8\u8BA4 JSON \u8F93\u51FA\uFF1BTIKTOK_BACKEND_URL \u6307\u5411 backend\u3002");
442
+ lines.push(` ${"auth link".padEnd(22)} \u751F\u6210\u5E97\u94FA\u7ED1\u5B9A\u6388\u6743\u94FE\u63A5\uFF08\u53D1\u7ED9\u5546\u5BB6\u70B9\u51FB\uFF09`);
443
+ lines.push(` ${"images upload".padEnd(22)} \u4E0A\u4F20\u672C\u5730\u56FE\u7247\uFF08--file --use-case [--out]\uFF09`);
444
+ lines.push("", "\u901A\u7528\uFF1A--shop \u5FC5\u586B\uFF08\u9664 shops / auth link\uFF09\uFF1B\u9ED8\u8BA4 JSON \u8F93\u51FA\uFF1BTIKTOK_BACKEND_URL \u663E\u5F0F\u6307\u5B9A backend\uFF08\u672A\u8BBE\u5219\u6309 OPTIMA_ENV \u6620\u5C04 cn-stage/cn-prod\uFF0C\u5426\u5219 localhost\uFF09\u3002");
272
445
  process.stderr.write(lines.join("\n") + "\n");
273
446
  }
274
447
  var argv = process.argv.slice(2);
@@ -281,6 +454,27 @@ if (positional[0] === "shops") {
281
454
  out(await listShops());
282
455
  process.exit(0);
283
456
  }
457
+ if (positional[0] === "auth" && positional[1] === "link") {
458
+ out(await createAuthLink());
459
+ process.exit(0);
460
+ }
461
+ if (positional[0] === "images" && positional[1] === "upload") {
462
+ if (!args.file) {
463
+ out({ ok: false, error: { code: "VALIDATION", message: "\u7F3A\u5C11 --file", retryable: false } });
464
+ process.exit(1);
465
+ }
466
+ if (!args.shop) {
467
+ out({ ok: false, error: { code: "VALIDATION", message: "\u7F3A\u5C11 --shop", retryable: false } });
468
+ process.exit(1);
469
+ }
470
+ const r = await uploadImage(args.shop, args.file, args["use-case"] ?? "MAIN_IMAGE");
471
+ if (r.ok && args.out) {
472
+ const { appendFileSync } = await import("fs");
473
+ appendFileSync(args.out, JSON.stringify(r.data) + "\n");
474
+ }
475
+ out(r);
476
+ process.exit(0);
477
+ }
284
478
  var match = findCommand(positional);
285
479
  if (!match) {
286
480
  out({ ok: false, error: { code: "VALIDATION", message: `\u672A\u77E5\u547D\u4EE4: ${positional.join(" ")}`, retryable: false } });
@@ -293,6 +487,16 @@ if (!args.shop) {
293
487
  }
294
488
  var ctx = { shop: args.shop, args, actor: args.actor };
295
489
  if (match.cmd.write && !("apply" in args)) {
490
+ if (match.cmd.precheck) {
491
+ const pc = await match.cmd.precheck(ctx);
492
+ out({
493
+ ok: true,
494
+ dry_run: true,
495
+ precheck: pc.ok ? pc.data : { error: pc.error },
496
+ message: "listing_check \u9884\u68C0\u7ED3\u679C\uFF0C\u786E\u8BA4\u540E\u52A0 --apply \u6267\u884C\u3002"
497
+ });
498
+ process.exit(0);
499
+ }
296
500
  const plan = match.cmd.preview?.(ctx);
297
501
  out({
298
502
  ok: true,
@@ -303,9 +507,16 @@ if (match.cmd.write && !("apply" in args)) {
303
507
  process.exit(0);
304
508
  }
305
509
  var env = await match.cmd.run(ctx);
306
- if (match.cmd.write && env.ok && match.cmd.readback) {
307
- const rb = await match.cmd.readback(ctx);
308
- out({ ...env, applied: true, readback: rb.ok ? rb.data : { error: rb.error } });
510
+ if (match.cmd.write && env.ok) {
511
+ const result = { ...env, applied: true };
512
+ if (env.data?.warnings?.length) {
513
+ result.warnings_alert = "\u26A0\uFE0F \u6709\u5B57\u6BB5\u88AB\u9759\u9ED8\u5904\u7406\uFF0C\u89C1 warnings";
514
+ }
515
+ if (match.cmd.readback) {
516
+ const rb = await match.cmd.readback(ctx);
517
+ result.readback = rb.ok ? rb.data : { error: rb.error };
518
+ }
519
+ out(result);
309
520
  } else {
310
521
  out(env);
311
522
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optima-chat/tiktok-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "TikTok Shop CLI for Optima agents — 经 tiktok-backend 操作店铺",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "build": "tsup src/index.ts --format esm --clean",
11
11
  "dev": "tsx src/index.ts",
12
12
  "typecheck": "tsc --noEmit",
13
+ "test": "node --import tsx --test tests/*.test.mts",
13
14
  "prepublishOnly": "npm run build"
14
15
  },
15
16
  "author": "Optima Commerce Team",