@optima-chat/tiktok-cli 0.2.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 +23 -1
  2. package/dist/index.js +187 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,7 +19,7 @@ tiktok <domain> <verb> --shop <shopId> [--flags]
19
19
  | 命令 | 说明 |
20
20
  |---|---|
21
21
  | `auth link` | 生成店铺绑定授权链接(无需 --shop) |
22
- | `shops --merchant <id>` | 查商家已授权店铺 |
22
+ | `shops` | 查当前登录用户已授权店铺 |
23
23
  | `orders list` | 订单列表(`--status --page-size --since`) |
24
24
  | `orders get --id <id>` | 订单详情 |
25
25
  | `analytics shop` | 店铺经营数据(`--from --to`,默认近30天) |
@@ -28,6 +28,22 @@ tiktok <domain> <verb> --shop <shopId> [--flags]
28
28
  | `promotions list` | 促销活动(`--status`) |
29
29
  | `finance statements` | 结算单 |
30
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
+
31
47
  ## 写操作(Phase 2,安全护栏)
32
48
 
33
49
  写命令三道阀,把 A 轨「填完回读」纪律做进 CLI:
@@ -49,9 +65,15 @@ tiktok products set-price --shop <id> --product <pid> --sku <sid> --price 29.90
49
65
  | `products set-inventory` | 改 SKU 库存(`--product --sku --warehouse --quantity`) | ✓ 49→55→49 可逆 |
50
66
  | `promotions create-flashsale` | 建秒杀(`--title --product --sku --price [--limit --per-user --hours]`)。两步:建壳+加品 | ✓ 真实建成 |
51
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>` | 改商品字段(嵌套字段整组覆盖,未列子项会被清空) | — |
52
72
 
53
73
  均在跨境测试店真实验证(2026-09-02):`ok/applied` → 回读确认 → 审计落库。
54
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
+
55
77
  **秒杀字段坑**(OAS 未标、实测撞出):商品级 `quantity_limit` 在 VARIATION 模式必须 `-1`(限量由 SKU 级控制);秒杀价须**低于该 SKU 近 30 天最低成交价**且**不超折扣上限**。这些复杂度被 CLI 吸收,agent 一次成型。
56
78
 
57
79
  ## 错误分类(agent 靠此决策)
package/dist/index.js CHANGED
@@ -1,8 +1,12 @@
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
11
 
8
12
  // src/backendUrl.ts
@@ -81,8 +85,37 @@ async function createAuthLink() {
81
85
  return { ok: false, error: { code: "BACKEND_UNREACHABLE", message: e.message, retryable: true } };
82
86
  }
83
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
+ }
84
102
 
85
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
+ }
86
119
  var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
87
120
  var daysAgo = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);
88
121
  var COMMANDS = [
@@ -152,6 +185,78 @@ var COMMANDS = [
152
185
  }
153
186
  })
154
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
+ },
155
260
  // ===== 写操作(Phase 2):默认 --dry-run,--apply 才真发,成功后回读 =====
156
261
  {
157
262
  name: "products set-price",
@@ -209,6 +314,49 @@ var COMMANDS = [
209
314
  }],
210
315
  run: ({ shop, args: args2 }) => proxy("POST", shop, `/product/202309/products/${args2.product}/inventory/update`, { body: inventoryBody(args2) }),
211
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}`, {})
212
360
  }
213
361
  ];
214
362
  function inventoryBody(args2) {
@@ -292,7 +440,8 @@ function usage() {
292
440
  for (const c of COMMANDS) lines.push(` ${c.name.padEnd(22)} ${c.summary}`);
293
441
  lines.push(` ${"shops".padEnd(22)} \u67E5\u5F53\u524D\u767B\u5F55\u7528\u6237\u5DF2\u6388\u6743\u5E97\u94FA`);
294
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`);
295
- 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");
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");
296
445
  process.stderr.write(lines.join("\n") + "\n");
297
446
  }
298
447
  var argv = process.argv.slice(2);
@@ -309,6 +458,23 @@ if (positional[0] === "auth" && positional[1] === "link") {
309
458
  out(await createAuthLink());
310
459
  process.exit(0);
311
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
+ }
312
478
  var match = findCommand(positional);
313
479
  if (!match) {
314
480
  out({ ok: false, error: { code: "VALIDATION", message: `\u672A\u77E5\u547D\u4EE4: ${positional.join(" ")}`, retryable: false } });
@@ -321,6 +487,16 @@ if (!args.shop) {
321
487
  }
322
488
  var ctx = { shop: args.shop, args, actor: args.actor };
323
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
+ }
324
500
  const plan = match.cmd.preview?.(ctx);
325
501
  out({
326
502
  ok: true,
@@ -331,9 +507,16 @@ if (match.cmd.write && !("apply" in args)) {
331
507
  process.exit(0);
332
508
  }
333
509
  var env = await match.cmd.run(ctx);
334
- if (match.cmd.write && env.ok && match.cmd.readback) {
335
- const rb = await match.cmd.readback(ctx);
336
- 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);
337
520
  } else {
338
521
  out(env);
339
522
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optima-chat/tiktok-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "TikTok Shop CLI for Optima agents — 经 tiktok-backend 操作店铺",
5
5
  "type": "module",
6
6
  "bin": {