siluzan-tso-cli 1.1.22-beta.12 → 1.1.22-beta.15

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
@@ -51,7 +51,7 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
51
51
  siluzan-tso init --force # 强制覆盖已存在文件
52
52
  ```
53
53
 
54
- > **注意**:当前为测试版(1.1.22-beta.12),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.22-beta.15),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -2262,13 +2262,13 @@ function redactSensitive(input) {
2262
2262
  }
2263
2263
  async function apiFetch(url, config, options = {}, verbose = false) {
2264
2264
  const method = options.method ?? "GET";
2265
- const authHeaders = config.apiKey ? { "x-api-key": config.apiKey } : { Authorization: `Bearer ${config.authToken}` };
2265
+ const authHeaders2 = config.apiKey ? { "x-api-key": config.apiKey } : { Authorization: `Bearer ${config.authToken}` };
2266
2266
  const reqHeaders = {
2267
2267
  "Content-Type": "application/json",
2268
2268
  "Accept-Language": "zh-CN",
2269
2269
  // 声明支持 gzip/deflate/br;服务端不支持则按 identity 返回,rawRequest 会原样收取
2270
2270
  "Accept-Encoding": "gzip, deflate, br",
2271
- ...authHeaders,
2271
+ ...authHeaders2,
2272
2272
  // dataPermission 仅 TSO 使用;CSO 未设置时为空字符串,服务端忽略该头
2273
2273
  Datapermission: config.dataPermission ?? "",
2274
2274
  ...options.headers ?? {}
@@ -2296,12 +2296,12 @@ async function apiFetch(url, config, options = {}, verbose = false) {
2296
2296
  }
2297
2297
  async function apiFetchWithHeaders(url, config, options = {}, verbose = false) {
2298
2298
  const method = options.method ?? "GET";
2299
- const authHeaders = config.apiKey ? { "x-api-key": config.apiKey } : { Authorization: `Bearer ${config.authToken}` };
2299
+ const authHeaders2 = config.apiKey ? { "x-api-key": config.apiKey } : { Authorization: `Bearer ${config.authToken}` };
2300
2300
  const reqHeaders = {
2301
2301
  "Content-Type": "application/json",
2302
2302
  "Accept-Language": "zh-CN",
2303
2303
  "Accept-Encoding": "gzip, deflate, br",
2304
- ...authHeaders,
2304
+ ...authHeaders2,
2305
2305
  Datapermission: config.dataPermission ?? "",
2306
2306
  ...options.headers ?? {}
2307
2307
  };
@@ -2616,14 +2616,14 @@ function parseMeResponse(text) {
2616
2616
  async function fetchSiluzanCurrentUser(apiBase, config) {
2617
2617
  const mainOrigin = deriveMainApiOrigin(apiBase);
2618
2618
  const meUrl = `${mainOrigin.replace(/\/$/, "")}/query/account/me`;
2619
- const authHeaders = config.apiKey ? { "x-api-key": config.apiKey } : { Authorization: `Bearer ${config.authToken}` };
2619
+ const authHeaders2 = config.apiKey ? { "x-api-key": config.apiKey } : { Authorization: `Bearer ${config.authToken}` };
2620
2620
  try {
2621
2621
  const res = await rawRequest(meUrl, {
2622
2622
  method: "GET",
2623
2623
  headers: {
2624
2624
  "Content-Type": "application/json",
2625
2625
  "Accept-Language": "zh-CN",
2626
- ...authHeaders
2626
+ ...authHeaders2
2627
2627
  }
2628
2628
  });
2629
2629
  if (res.status < 200 || res.status >= 300) return null;
@@ -100904,15 +100904,15 @@ function computeBackoffMs(attempt, policy) {
100904
100904
  const jitter = Math.floor(Math.random() * policy.baseMs);
100905
100905
  return capped + jitter;
100906
100906
  }
100907
- function sleep2(ms, signal) {
100908
- return new Promise((resolve12, reject) => {
100907
+ function sleep3(ms, signal) {
100908
+ return new Promise((resolve13, reject) => {
100909
100909
  if (signal?.aborted) {
100910
100910
  reject(new Error("retry sleep aborted"));
100911
100911
  return;
100912
100912
  }
100913
100913
  const timer = setTimeout(() => {
100914
100914
  signal?.removeEventListener("abort", onAbort);
100915
- resolve12();
100915
+ resolve13();
100916
100916
  }, ms);
100917
100917
  const onAbort = () => {
100918
100918
  clearTimeout(timer);
@@ -100973,7 +100973,7 @@ async function fetchWithRetry(factory, options) {
100973
100973
  const backoffMs = computeBackoffMs(attempt, policy);
100974
100974
  options.onRetry?.({ attempt, backoffMs, status: cls.status, error: err });
100975
100975
  try {
100976
- await sleep2(backoffMs, options.signal);
100976
+ await sleep3(backoffMs, options.signal);
100977
100977
  } catch {
100978
100978
  throw new AbortRunError({
100979
100979
  message: `\u91CD\u8BD5\u7B49\u5F85\u88AB\u4E2D\u6B62\uFF1A${options.label}`,
@@ -113852,6 +113852,10 @@ function runPmaxCreateValidation(cfg) {
113852
113852
  }
113853
113853
  }
113854
113854
  }
113855
+ const youtube = cfg.youtubeUrlOrId?.trim();
113856
+ if (youtube && youtube.length < 6) {
113857
+ pushErr3(errors, "youtubeUrlOrId \u8FC7\u77ED\uFF0C\u8BF7\u586B\u5199\u5B8C\u6574 YouTube URL \u6216 11 \u4F4D\u89C6\u9891 ID");
113858
+ }
113855
113859
  validateTextLengths(
113856
113860
  errors,
113857
113861
  lengthViolations,
@@ -114319,8 +114323,129 @@ function assertPmaxImageSlotsResolved(slots) {
114319
114323
  }
114320
114324
  }
114321
114325
 
114322
- // src/commands/ad/pmax-load.ts
114326
+ // src/commands/ad/pmax-shared.ts
114327
+ init_auth();
114328
+ init_cli_json_snapshot();
114323
114329
  import { readFileSync as readFileSync6 } from "fs";
114330
+ import { dirname as dirname9, isAbsolute as isAbsolute4, resolve as resolve8 } from "path";
114331
+ var PMAX_MONEY_KEYS = /* @__PURE__ */ new Set(["budget", "targetCpa_BidingAmount"]);
114332
+ function loadPmaxJsonFile(configFile) {
114333
+ let raw;
114334
+ try {
114335
+ raw = JSON.parse(readFileSync6(configFile, "utf8"));
114336
+ } catch (e) {
114337
+ const msg = e instanceof Error ? e.message : String(e);
114338
+ console.error(`
114339
+ \u274C \u8BFB\u53D6 JSON \u5931\u8D25\uFF08${configFile}\uFF09\uFF1A${msg}
114340
+ `);
114341
+ process.exit(1);
114342
+ }
114343
+ return stripMetaKeys(raw);
114344
+ }
114345
+ function resolvePmaxImagePath2(configFile, relOrAbs) {
114346
+ const trimmed = relOrAbs.trim();
114347
+ if (isAbsolute4(trimmed)) return trimmed;
114348
+ return resolve8(dirname9(configFile), trimmed);
114349
+ }
114350
+ function convertPmaxMoneyInObject(body) {
114351
+ const out = { ...body };
114352
+ for (const key of PMAX_MONEY_KEYS) {
114353
+ const val = out[key];
114354
+ if (typeof val === "number" && Number.isFinite(val)) {
114355
+ out[key] = toCentAmount(val);
114356
+ }
114357
+ }
114358
+ return out;
114359
+ }
114360
+ function buildPmaxAssetGroupApiBody(cfg, imageSlots) {
114361
+ assertPmaxImageSlotsResolved(imageSlots);
114362
+ return {
114363
+ name: cfg.name.trim(),
114364
+ finalUrls: cfg.finalUrls.map((u) => u.trim()).filter(Boolean),
114365
+ headlines: cfg.headlines.map((h) => h.trim()).filter(Boolean),
114366
+ longHeadlines: cfg.longHeadlines.map((h) => h.trim()).filter(Boolean),
114367
+ descriptions: cfg.descriptions.map((d) => d.trim()).filter(Boolean),
114368
+ businessName: cfg.businessName.trim(),
114369
+ ...imageSlots
114370
+ };
114371
+ }
114372
+ async function processAssetsUpdateBodyWithUpload(configFile, body, accountId, config, googleApiUrl, verbose) {
114373
+ const out = { ...body };
114374
+ const links = out["assetsToLink"];
114375
+ if (!Array.isArray(links)) return out;
114376
+ out["assetsToLink"] = await Promise.all(
114377
+ links.map(async (item) => {
114378
+ if (!item || typeof item !== "object") return item;
114379
+ const row = { ...item };
114380
+ const imagePath = row["imagePath"];
114381
+ if (typeof imagePath === "string" && imagePath.trim()) {
114382
+ const abs = resolvePmaxImagePath2(configFile, imagePath);
114383
+ row["assetId"] = await uploadPmaxImageFile(
114384
+ config,
114385
+ googleApiUrl,
114386
+ accountId,
114387
+ abs,
114388
+ void 0,
114389
+ verbose
114390
+ );
114391
+ delete row["imagePath"];
114392
+ delete row["imageBase64"];
114393
+ }
114394
+ return row;
114395
+ })
114396
+ );
114397
+ return out;
114398
+ }
114399
+ async function withGoogleApi(opts, fn) {
114400
+ const config = await ensureDataPermission(loadConfig(opts.token));
114401
+ const googleApiUrl = requireGoogleApi(config);
114402
+ return fn(config, googleApiUrl);
114403
+ }
114404
+ async function pmaxApiFetch(url, config, init, verbose) {
114405
+ return apiFetch2(url, config, init ?? {}, verbose);
114406
+ }
114407
+ async function postPmaxYoutubeLink(opts) {
114408
+ const body = {
114409
+ youtubeUrlOrId: opts.youtubeUrlOrId.trim()
114410
+ };
114411
+ if (opts.campaignId?.trim()) body["campaignId"] = opts.campaignId.trim();
114412
+ if (opts.assetName?.trim()) body["assetName"] = opts.assetName.trim();
114413
+ const url = pmaxAssetGroupUrl(
114414
+ opts.googleApiUrl,
114415
+ opts.accountId,
114416
+ opts.assetGroupId,
114417
+ "youtube"
114418
+ );
114419
+ const raw = await pmaxApiFetch(
114420
+ url,
114421
+ opts.config,
114422
+ { method: "POST", body: JSON.stringify(body) },
114423
+ opts.verbose
114424
+ );
114425
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { data: raw };
114426
+ }
114427
+ async function emitPmaxResult(opts, section, commandLabel, payload, idSuffix) {
114428
+ return emitCliJsonOrSnapshot(opts, {
114429
+ section,
114430
+ commandLabel,
114431
+ commandHint: idSuffix ?? opts.account ?? "",
114432
+ payload,
114433
+ idSuffix: idSuffix ?? opts.account
114434
+ });
114435
+ }
114436
+ function requireAccountId(account, label = "-a, --account") {
114437
+ const id = account?.toString().trim();
114438
+ if (!id) {
114439
+ console.error(`
114440
+ \u274C \u8BF7\u6307\u5B9A\u5A92\u4F53\u8D26\u6237 ID\uFF08${label}\uFF09
114441
+ `);
114442
+ process.exit(1);
114443
+ }
114444
+ return id;
114445
+ }
114446
+
114447
+ // src/commands/ad/pmax-load.ts
114448
+ import { readFileSync as readFileSync7 } from "fs";
114324
114449
  function loadPmaxCreateConfig(configFile) {
114325
114450
  const cfg = tryLoadPmaxCreateConfig(configFile);
114326
114451
  if (!cfg) {
@@ -114331,15 +114456,36 @@ function loadPmaxCreateConfig(configFile) {
114331
114456
  }
114332
114457
  return cfg;
114333
114458
  }
114459
+ var DEPRECATED_PMAX_VIDEO_KEYS = ["videoPaths", "localVideo", "videoFile", "video"];
114460
+ function detectDeprecatedPmaxVideoKeys(raw) {
114461
+ const msgs = [];
114462
+ for (const key of DEPRECATED_PMAX_VIDEO_KEYS) {
114463
+ const v = raw[key];
114464
+ if (v == null) continue;
114465
+ if (typeof v === "string" && !v.trim()) continue;
114466
+ if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) {
114467
+ continue;
114468
+ }
114469
+ msgs.push(`\u914D\u7F6E\u542B\u5DF2\u5E9F\u5F03\u5B57\u6BB5\u300C${key}\u300D\uFF0C\u8BF7\u6539\u7528 videoPath\uFF08\u672C\u5730\u6587\u4EF6\uFF09\u6216 youtubeUrlOrId`);
114470
+ }
114471
+ return msgs;
114472
+ }
114334
114473
  function tryLoadPmaxCreateConfig(configFile) {
114335
114474
  let raw;
114336
114475
  try {
114337
- raw = JSON.parse(readFileSync6(configFile, "utf8"));
114476
+ raw = JSON.parse(readFileSync7(configFile, "utf8"));
114338
114477
  } catch {
114339
114478
  return null;
114340
114479
  }
114341
114480
  return stripMetaKeys(raw);
114342
114481
  }
114482
+ function loadPmaxCreateConfigRaw(configFile) {
114483
+ try {
114484
+ return stripMetaKeys(JSON.parse(readFileSync7(configFile, "utf8")));
114485
+ } catch {
114486
+ return null;
114487
+ }
114488
+ }
114343
114489
  function parseLocationLanguageIds(items) {
114344
114490
  if (!items?.length) return void 0;
114345
114491
  return items.map((item) => ({
@@ -114381,9 +114527,202 @@ function buildPmaxCreateUrl(googleApiUrl, accountId) {
114381
114527
  return `${base}/accounts/${accountId}/campaign/pmax`;
114382
114528
  }
114383
114529
 
114530
+ // src/commands/ad/pmax-video-upload.ts
114531
+ import { existsSync as existsSync3, readFileSync as readFileSync8 } from "fs";
114532
+ import { basename as basename5, dirname as dirname10, isAbsolute as isAbsolute5, resolve as resolve9 } from "path";
114533
+ var VIDEO_UPLOAD_SUFFIX = /\.(mp4|mov|webm|avi|mpeg|mpg)$/i;
114534
+ var TERMINAL_VIDEO_STATES = /* @__PURE__ */ new Set(["PROCESSED", "FAILED", "REJECTED", "UNAVAILABLE", "NOT_FOUND"]);
114535
+ var FAILED_VIDEO_STATES = /* @__PURE__ */ new Set(["FAILED", "REJECTED", "UNAVAILABLE", "NOT_FOUND"]);
114536
+ var PMAX_VIDEO_POLL_INTERVAL_MS = 1e4;
114537
+ var PMAX_VIDEO_POLL_MAX_ATTEMPTS = 120;
114538
+ function pyapiVideoUploadUrl(googleApiUrl) {
114539
+ const base = googleApiUrl.replace(/\/$/, "");
114540
+ return `${base}/pyapi/video/upload`;
114541
+ }
114542
+ function pyapiVideoStatusUrl(googleApiUrl, mediaAccountId, resourceName) {
114543
+ const base = googleApiUrl.replace(/\/$/, "");
114544
+ const params = new URLSearchParams({
114545
+ media_account_id: mediaAccountId.replace(/-/g, ""),
114546
+ resource_name: resourceName
114547
+ });
114548
+ return `${base}/pyapi/video/upload/status?${params.toString()}`;
114549
+ }
114550
+ function resolveVideoPath(configFile, relOrAbs) {
114551
+ const trimmed = relOrAbs.trim();
114552
+ if (isAbsolute5(trimmed)) return trimmed;
114553
+ return resolve9(dirname10(configFile), trimmed);
114554
+ }
114555
+ function authHeaders(config) {
114556
+ const headers = {
114557
+ "Accept-Language": "zh-CN",
114558
+ Datapermission: config.dataPermission ?? ""
114559
+ };
114560
+ if (config.apiKey) headers["x-api-key"] = config.apiKey;
114561
+ else headers.Authorization = `Bearer ${config.authToken}`;
114562
+ return headers;
114563
+ }
114564
+ function guessVideoMimeType(fileName) {
114565
+ const lower = fileName.toLowerCase();
114566
+ if (lower.endsWith(".mp4")) return "video/mp4";
114567
+ if (lower.endsWith(".mov")) return "video/quicktime";
114568
+ if (lower.endsWith(".webm")) return "video/webm";
114569
+ if (lower.endsWith(".avi")) return "video/x-msvideo";
114570
+ if (lower.endsWith(".mpeg") || lower.endsWith(".mpg")) return "video/mpeg";
114571
+ return "application/octet-stream";
114572
+ }
114573
+ function runPmaxVideoValidation(configFile, cfg) {
114574
+ const errors = [];
114575
+ const warnings = [];
114576
+ const videoPath = cfg.videoPath?.trim();
114577
+ const youtube = cfg.youtubeUrlOrId?.trim();
114578
+ if (videoPath && youtube) {
114579
+ errors.push("videoPath \u4E0E youtubeUrlOrId \u53EA\u80FD\u4E8C\u9009\u4E00\uFF08\u672C\u5730\u6587\u4EF6\u8D70 PyAPI \u4E0A\u4F20\uFF0C\u540E\u8005\u4E3A\u5DF2\u6709 YouTube\uFF09");
114580
+ return { errors, warnings };
114581
+ }
114582
+ if (!videoPath) return { errors, warnings };
114583
+ if (!VIDEO_UPLOAD_SUFFIX.test(videoPath)) {
114584
+ errors.push(
114585
+ `videoPath \u6269\u5C55\u540D\u4E0D\u53D7\u652F\u6301\uFF08${videoPath}\uFF09\uFF0C\u8BF7\u4F7F\u7528 .mp4 / .mov / .webm / .avi / .mpeg / .mpg`
114586
+ );
114587
+ }
114588
+ const abs = resolveVideoPath(configFile, videoPath);
114589
+ if (!existsSync3(abs)) {
114590
+ errors.push(`videoPath \u6587\u4EF6\u4E0D\u5B58\u5728\uFF1A${abs}`);
114591
+ return { errors, warnings };
114592
+ }
114593
+ try {
114594
+ const buf = readFileSync8(abs);
114595
+ if (buf.length === 0) errors.push(`videoPath \u4E3A\u7A7A\u6587\u4EF6\uFF1A${abs}`);
114596
+ } catch (e) {
114597
+ const msg = e instanceof Error ? e.message : String(e);
114598
+ errors.push(`\u65E0\u6CD5\u8BFB\u53D6 videoPath\uFF08${abs}\uFF09\uFF1A${msg}`);
114599
+ }
114600
+ if (!cfg.videoTitle?.trim()) {
114601
+ warnings.push("\u672A\u8BBE\u7F6E videoTitle\uFF0C\u5C06\u4F7F\u7528\u6587\u4EF6\u540D\u4F5C\u4E3A YouTube \u4E0A\u4F20\u6807\u9898");
114602
+ }
114603
+ return { errors, warnings };
114604
+ }
114605
+ async function fetchVideoUploadStatus(config, googleApiUrl, mediaAccountId, resourceName, verbose) {
114606
+ const url = pyapiVideoStatusUrl(googleApiUrl, mediaAccountId, resourceName);
114607
+ const res = await fetch(url, { method: "GET", headers: authHeaders(config) });
114608
+ const text = await res.text();
114609
+ if (!res.ok) {
114610
+ const detail = verbose ? `\uFF1A${text.slice(0, 300)}` : "";
114611
+ throw new Error(`\u67E5\u8BE2\u89C6\u9891\u5904\u7406\u72B6\u6001 HTTP ${res.status}${detail}`);
114612
+ }
114613
+ try {
114614
+ return JSON.parse(text);
114615
+ } catch {
114616
+ throw new Error("\u67E5\u8BE2\u89C6\u9891\u5904\u7406\u72B6\u6001\u54CD\u5E94\u975E JSON");
114617
+ }
114618
+ }
114619
+ function sleep2(ms) {
114620
+ return new Promise((r) => setTimeout(r, ms));
114621
+ }
114622
+ async function pollVideoUntilProcessed(config, googleApiUrl, mediaAccountId, resourceName, verbose, onProgress) {
114623
+ for (let attempt = 1; attempt <= PMAX_VIDEO_POLL_MAX_ATTEMPTS; attempt++) {
114624
+ const status = await fetchVideoUploadStatus(
114625
+ config,
114626
+ googleApiUrl,
114627
+ mediaAccountId,
114628
+ resourceName,
114629
+ verbose
114630
+ );
114631
+ const state = (status.state ?? "UNKNOWN").toUpperCase();
114632
+ onProgress?.(state, attempt);
114633
+ if (status.video_id?.trim()) {
114634
+ return status.video_id.trim();
114635
+ }
114636
+ if (FAILED_VIDEO_STATES.has(state)) {
114637
+ throw new Error(`\u89C6\u9891\u5904\u7406\u5931\u8D25\uFF08state=${state}\uFF0Cresource=${resourceName}\uFF09`);
114638
+ }
114639
+ if (TERMINAL_VIDEO_STATES.has(state) && state === "PROCESSED" && !status.video_id) {
114640
+ throw new Error(`\u89C6\u9891\u5DF2 PROCESSED \u4F46\u672A\u8FD4\u56DE video_id\uFF08resource=${resourceName}\uFF09`);
114641
+ }
114642
+ if (TERMINAL_VIDEO_STATES.has(state) && state !== "PROCESSED") {
114643
+ throw new Error(`\u89C6\u9891\u5904\u7406\u7ED3\u675F\u4F46\u65E0 video_id\uFF08state=${state}\uFF09`);
114644
+ }
114645
+ if (attempt < PMAX_VIDEO_POLL_MAX_ATTEMPTS) {
114646
+ await sleep2(PMAX_VIDEO_POLL_INTERVAL_MS);
114647
+ }
114648
+ }
114649
+ throw new Error(
114650
+ `\u7B49\u5F85\u89C6\u9891\u5904\u7406\u8D85\u65F6\uFF08\u5DF2\u8F6E\u8BE2 ${PMAX_VIDEO_POLL_MAX_ATTEMPTS} \u6B21\uFF0C\u7EA6 ${PMAX_VIDEO_POLL_MAX_ATTEMPTS * PMAX_VIDEO_POLL_INTERVAL_MS / 6e4} \u5206\u949F\uFF09`
114651
+ );
114652
+ }
114653
+ async function uploadPmaxLocalVideo(opts) {
114654
+ const fileName = basename5(opts.absVideoPath);
114655
+ const title = (opts.title ?? fileName).trim() || fileName;
114656
+ const description = (opts.description ?? "Uploaded via siluzan-tso PMax video upload").trim() || "Uploaded via siluzan-tso";
114657
+ const fileBuffer = readFileSync8(opts.absVideoPath);
114658
+ const form = new FormData();
114659
+ form.append("media_account_id", opts.accountId.replace(/-/g, ""));
114660
+ form.append("title", title);
114661
+ form.append("description", description);
114662
+ form.append("wait_for_processed", "false");
114663
+ form.append(
114664
+ "file",
114665
+ new Blob([new Uint8Array(fileBuffer)], { type: guessVideoMimeType(fileName) }),
114666
+ fileName
114667
+ );
114668
+ const url = pyapiVideoUploadUrl(opts.googleApiUrl);
114669
+ const res = await fetch(url, { method: "POST", headers: authHeaders(opts.config), body: form });
114670
+ const text = await res.text();
114671
+ if (!res.ok) {
114672
+ const detail = opts.verbose ? `\uFF1A${text.slice(0, 400)}` : "";
114673
+ throw new Error(`\u4E0A\u4F20\u89C6\u9891 HTTP ${res.status}${detail}\uFF08${opts.absVideoPath}\uFF09`);
114674
+ }
114675
+ let data;
114676
+ try {
114677
+ data = JSON.parse(text);
114678
+ } catch {
114679
+ throw new Error(`\u4E0A\u4F20\u89C6\u9891\u54CD\u5E94\u975E JSON\uFF08${opts.absVideoPath}\uFF09`);
114680
+ }
114681
+ if (data.video_id?.trim()) {
114682
+ return data.video_id.trim();
114683
+ }
114684
+ const resourceName = data.resource_name?.trim();
114685
+ if (!resourceName) {
114686
+ throw new Error(`\u4E0A\u4F20\u89C6\u9891\u672A\u8FD4\u56DE resource_name \u6216 video_id\uFF08${opts.absVideoPath}\uFF09`);
114687
+ }
114688
+ opts.onProgress?.(`\u5DF2\u4E0A\u4F20\uFF0C\u7B49\u5F85 Google \u5904\u7406\uFF08state=${data.state ?? "UPLOADED"}\uFF09\u2026`);
114689
+ return pollVideoUntilProcessed(
114690
+ opts.config,
114691
+ opts.googleApiUrl,
114692
+ opts.accountId,
114693
+ resourceName,
114694
+ opts.verbose,
114695
+ (state, attempt) => {
114696
+ opts.onProgress?.(`\u5904\u7406\u4E2D state=${state}\uFF08\u7B2C ${attempt}/${PMAX_VIDEO_POLL_MAX_ATTEMPTS} \u6B21\u67E5\u8BE2\uFF09`);
114697
+ }
114698
+ );
114699
+ }
114700
+ function resolvePmaxVideoAbsPath(relOrAbs, baseDir = process.cwd()) {
114701
+ const trimmed = relOrAbs.trim();
114702
+ if (isAbsolute5(trimmed)) return trimmed;
114703
+ return resolve9(baseDir, trimmed);
114704
+ }
114705
+ function validatePmaxVideoPathQuick(absPath) {
114706
+ if (!VIDEO_UPLOAD_SUFFIX.test(absPath)) {
114707
+ return `videoPath \u6269\u5C55\u540D\u4E0D\u53D7\u652F\u6301\uFF0C\u8BF7\u4F7F\u7528 .mp4 / .mov / .webm / .avi / .mpeg / .mpg`;
114708
+ }
114709
+ if (!existsSync3(absPath)) {
114710
+ return `\u89C6\u9891\u6587\u4EF6\u4E0D\u5B58\u5728\uFF1A${absPath}`;
114711
+ }
114712
+ try {
114713
+ if (readFileSync8(absPath).length === 0) return `\u89C6\u9891\u6587\u4EF6\u4E3A\u7A7A\uFF1A${absPath}`;
114714
+ } catch (e) {
114715
+ const msg = e instanceof Error ? e.message : String(e);
114716
+ return `\u65E0\u6CD5\u8BFB\u53D6\u89C6\u9891\u6587\u4EF6\uFF08${absPath}\uFF09\uFF1A${msg}`;
114717
+ }
114718
+ return null;
114719
+ }
114720
+
114384
114721
  // src/commands/ad/pmax-create.ts
114385
114722
  async function runAdPmaxCreate(opts) {
114386
114723
  const cfg = loadPmaxCreateConfig(opts.configFile);
114724
+ const rawCfg = loadPmaxCreateConfigRaw(opts.configFile) ?? {};
114725
+ const deprecatedVideoKeys = detectDeprecatedPmaxVideoKeys(rawCfg);
114387
114726
  const {
114388
114727
  errors: cfgErrors,
114389
114728
  warnings: cfgWarnings,
@@ -114393,8 +114732,12 @@ async function runAdPmaxCreate(opts) {
114393
114732
  opts.configFile,
114394
114733
  cfg
114395
114734
  );
114396
- const errors = [...cfgErrors, ...imgErrors];
114397
- const warnings = [...cfgWarnings, ...imgWarnings];
114735
+ const { errors: videoErrors, warnings: videoWarnings } = runPmaxVideoValidation(
114736
+ opts.configFile,
114737
+ cfg
114738
+ );
114739
+ const errors = [...deprecatedVideoKeys, ...cfgErrors, ...imgErrors, ...videoErrors];
114740
+ const warnings = [...cfgWarnings, ...imgWarnings, ...videoWarnings];
114398
114741
  if (warnings.length > 0) {
114399
114742
  console.warn("\n\u26A0\uFE0F PMax \u914D\u7F6E\u8B66\u544A\uFF08\u4E0D\u963B\u65AD\u63D0\u4EA4\uFF09\uFF1A");
114400
114743
  for (const w of warnings) console.warn(` \u2022 ${w}`);
@@ -114464,6 +114807,65 @@ async function runAdPmaxCreate(opts) {
114464
114807
  const campaignId = data["campaignId"] ?? data["campaign_id"];
114465
114808
  const assetGroupId = data["assetGroupId"] ?? data["asset_group_id"];
114466
114809
  const budgetId = data["budgetId"] ?? data["budget_id"];
114810
+ let youtubeTarget = cfg.youtubeUrlOrId?.trim();
114811
+ const videoPath = cfg.videoPath?.trim();
114812
+ if (videoPath && assetGroupId != null) {
114813
+ const absVideo = resolvePmaxImagePath2(opts.configFile, videoPath);
114814
+ if (!opts.jsonOut) console.log(`
114815
+ \u{1F4E4} \u4E0A\u4F20\u672C\u5730\u89C6\u9891\uFF08PyAPI\uFF09\u2026`);
114816
+ try {
114817
+ youtubeTarget = await uploadPmaxLocalVideo({
114818
+ config,
114819
+ googleApiUrl,
114820
+ accountId,
114821
+ absVideoPath: absVideo,
114822
+ title: cfg.videoTitle,
114823
+ description: cfg.videoDescription,
114824
+ verbose: opts.verbose,
114825
+ onProgress: opts.jsonOut ? void 0 : (msg) => console.log(` ${msg}`)
114826
+ });
114827
+ if (!opts.jsonOut) console.log(` video_id\uFF1A${youtubeTarget}`);
114828
+ } catch (err) {
114829
+ console.error(
114830
+ `
114831
+ \u26A0\uFE0F \u6D3B\u52A8\u5DF2\u521B\u5EFA\uFF0C\u4F46\u672C\u5730\u89C6\u9891\u4E0A\u4F20\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`
114832
+ );
114833
+ console.error(
114834
+ ` \u53EF\u624B\u52A8\u4E0A\u4F20\u540E\u94FE\u63A5\uFF1Asiluzan-tso ad pmax-youtube-link -a ${accountId} --asset-group-id ${assetGroupId}` + (campaignId != null ? ` --campaign-id ${campaignId}` : "") + ` --youtube "<video_id>"
114835
+ `
114836
+ );
114837
+ youtubeTarget = void 0;
114838
+ }
114839
+ }
114840
+ if (youtubeTarget && assetGroupId != null) {
114841
+ try {
114842
+ const yt = await postPmaxYoutubeLink({
114843
+ config,
114844
+ googleApiUrl,
114845
+ accountId,
114846
+ assetGroupId: String(assetGroupId),
114847
+ youtubeUrlOrId: youtubeTarget,
114848
+ campaignId: campaignId != null ? String(campaignId) : void 0,
114849
+ assetName: cfg.youtubeAssetName,
114850
+ verbose: opts.verbose
114851
+ });
114852
+ if (!opts.jsonOut) {
114853
+ console.log(`
114854
+ \u2705 \u5DF2\u94FE\u63A5 YouTube\uFF1A${youtubeTarget}`);
114855
+ const arn = yt["assetResourceName"];
114856
+ if (arn != null) console.log(` assetResourceName\uFF1A${arn}`);
114857
+ }
114858
+ } catch (err) {
114859
+ console.error(
114860
+ `
114861
+ \u26A0\uFE0F \u6D3B\u52A8\u5DF2\u521B\u5EFA\uFF0C\u4F46 YouTube \u94FE\u63A5\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`
114862
+ );
114863
+ console.error(
114864
+ ` \u8BF7\u624B\u52A8\u6267\u884C\uFF1Asiluzan-tso ad pmax-youtube-link -a ${accountId} --asset-group-id ${assetGroupId}` + (campaignId != null ? ` --campaign-id ${campaignId}` : "") + ` --youtube "${youtubeTarget}"
114865
+ `
114866
+ );
114867
+ }
114868
+ }
114467
114869
  console.log("\n\u2705 PMax \u5E7F\u544A\u7CFB\u5217\u5DF2\u521B\u5EFA\uFF08\u540C\u6B65\uFF09");
114468
114870
  console.log(` \u6D3B\u52A8\u540D\u79F0\uFF1A${cfg.name}`);
114469
114871
  if (campaignId != null) console.log(` campaignId\uFF1A${campaignId}`);
@@ -114473,6 +114875,11 @@ async function runAdPmaxCreate(opts) {
114473
114875
  `
114474
114876
  \u590D\u6838\uFF1Asiluzan-tso ad campaigns -a ${accountId} --json-out # channelTypeV2 \u5E94\u4E3A PERFORMANCE_MAX`
114475
114877
  );
114878
+ if (!youtubeTarget && !videoPath) {
114879
+ console.log(
114880
+ " \u89C6\u9891\uFF1A\u914D\u7F6E videoPath\uFF08\u672C\u5730\uFF09\u6216 youtubeUrlOrId \u53EF\u5728\u521B\u5EFA\u65F6\u81EA\u52A8\u94FE\u63A5\uFF1B\u6216\u521B\u5EFA\u540E ad pmax-youtube-link"
114881
+ );
114882
+ }
114476
114883
  console.log();
114477
114884
  }
114478
114885
 
@@ -114481,6 +114888,8 @@ import { writeFileSync as writeFileSync3 } from "fs";
114481
114888
  init_cli_json_snapshot();
114482
114889
  async function runAdPmaxValidate(opts) {
114483
114890
  const cfg = loadPmaxCreateConfig(opts.configFile);
114891
+ const rawCfg = loadPmaxCreateConfigRaw(opts.configFile) ?? {};
114892
+ const deprecatedVideoKeys = detectDeprecatedPmaxVideoKeys(rawCfg);
114484
114893
  const {
114485
114894
  errors: cfgErrors,
114486
114895
  warnings: cfgWarnings,
@@ -114490,8 +114899,12 @@ async function runAdPmaxValidate(opts) {
114490
114899
  opts.configFile,
114491
114900
  cfg
114492
114901
  );
114493
- const errors = [...cfgErrors, ...imgErrors];
114494
- const warnings = [...cfgWarnings, ...imgWarnings];
114902
+ const { errors: videoErrors, warnings: videoWarnings } = runPmaxVideoValidation(
114903
+ opts.configFile,
114904
+ cfg
114905
+ );
114906
+ const errors = [...deprecatedVideoKeys, ...cfgErrors, ...imgErrors, ...videoErrors];
114907
+ const warnings = [...cfgWarnings, ...imgWarnings, ...videoWarnings];
114495
114908
  const lengthViolations = cfgLengthViolations;
114496
114909
  if (opts.writeNormalized) {
114497
114910
  const toWrite = stripMetaKeysForExport(cfg);
@@ -114540,110 +114953,7 @@ async function runAdPmaxValidate(opts) {
114540
114953
  }
114541
114954
 
114542
114955
  // src/commands/ad/pmax-mgmt.ts
114543
- import { basename as basename5, isAbsolute as isAbsolute5 } from "path";
114544
-
114545
- // src/commands/ad/pmax-shared.ts
114546
- init_auth();
114547
- init_cli_json_snapshot();
114548
- import { readFileSync as readFileSync7 } from "fs";
114549
- import { dirname as dirname9, isAbsolute as isAbsolute4, resolve as resolve8 } from "path";
114550
- var PMAX_MONEY_KEYS = /* @__PURE__ */ new Set(["budget", "targetCpa_BidingAmount"]);
114551
- function loadPmaxJsonFile(configFile) {
114552
- let raw;
114553
- try {
114554
- raw = JSON.parse(readFileSync7(configFile, "utf8"));
114555
- } catch (e) {
114556
- const msg = e instanceof Error ? e.message : String(e);
114557
- console.error(`
114558
- \u274C \u8BFB\u53D6 JSON \u5931\u8D25\uFF08${configFile}\uFF09\uFF1A${msg}
114559
- `);
114560
- process.exit(1);
114561
- }
114562
- return stripMetaKeys(raw);
114563
- }
114564
- function resolvePmaxImagePath2(configFile, relOrAbs) {
114565
- const trimmed = relOrAbs.trim();
114566
- if (isAbsolute4(trimmed)) return trimmed;
114567
- return resolve8(dirname9(configFile), trimmed);
114568
- }
114569
- function convertPmaxMoneyInObject(body) {
114570
- const out = { ...body };
114571
- for (const key of PMAX_MONEY_KEYS) {
114572
- const val = out[key];
114573
- if (typeof val === "number" && Number.isFinite(val)) {
114574
- out[key] = toCentAmount(val);
114575
- }
114576
- }
114577
- return out;
114578
- }
114579
- function buildPmaxAssetGroupApiBody(cfg, imageSlots) {
114580
- assertPmaxImageSlotsResolved(imageSlots);
114581
- return {
114582
- name: cfg.name.trim(),
114583
- finalUrls: cfg.finalUrls.map((u) => u.trim()).filter(Boolean),
114584
- headlines: cfg.headlines.map((h) => h.trim()).filter(Boolean),
114585
- longHeadlines: cfg.longHeadlines.map((h) => h.trim()).filter(Boolean),
114586
- descriptions: cfg.descriptions.map((d) => d.trim()).filter(Boolean),
114587
- businessName: cfg.businessName.trim(),
114588
- ...imageSlots
114589
- };
114590
- }
114591
- async function processAssetsUpdateBodyWithUpload(configFile, body, accountId, config, googleApiUrl, verbose) {
114592
- const out = { ...body };
114593
- const links = out["assetsToLink"];
114594
- if (!Array.isArray(links)) return out;
114595
- out["assetsToLink"] = await Promise.all(
114596
- links.map(async (item) => {
114597
- if (!item || typeof item !== "object") return item;
114598
- const row = { ...item };
114599
- const imagePath = row["imagePath"];
114600
- if (typeof imagePath === "string" && imagePath.trim()) {
114601
- const abs = resolvePmaxImagePath2(configFile, imagePath);
114602
- row["assetId"] = await uploadPmaxImageFile(
114603
- config,
114604
- googleApiUrl,
114605
- accountId,
114606
- abs,
114607
- void 0,
114608
- verbose
114609
- );
114610
- delete row["imagePath"];
114611
- delete row["imageBase64"];
114612
- }
114613
- return row;
114614
- })
114615
- );
114616
- return out;
114617
- }
114618
- async function withGoogleApi(opts, fn) {
114619
- const config = await ensureDataPermission(loadConfig(opts.token));
114620
- const googleApiUrl = requireGoogleApi(config);
114621
- return fn(config, googleApiUrl);
114622
- }
114623
- async function pmaxApiFetch(url, config, init, verbose) {
114624
- return apiFetch2(url, config, init ?? {}, verbose);
114625
- }
114626
- async function emitPmaxResult(opts, section, commandLabel, payload, idSuffix) {
114627
- return emitCliJsonOrSnapshot(opts, {
114628
- section,
114629
- commandLabel,
114630
- commandHint: idSuffix ?? opts.account ?? "",
114631
- payload,
114632
- idSuffix: idSuffix ?? opts.account
114633
- });
114634
- }
114635
- function requireAccountId(account, label = "-a, --account") {
114636
- const id = account?.toString().trim();
114637
- if (!id) {
114638
- console.error(`
114639
- \u274C \u8BF7\u6307\u5B9A\u5A92\u4F53\u8D26\u6237 ID\uFF08${label}\uFF09
114640
- `);
114641
- process.exit(1);
114642
- }
114643
- return id;
114644
- }
114645
-
114646
- // src/commands/ad/pmax-mgmt.ts
114956
+ import { basename as basename6, isAbsolute as isAbsolute6 } from "path";
114647
114957
  function buildReportQuery(opts) {
114648
114958
  const params = new URLSearchParams();
114649
114959
  params.set("startDate", toGoogleDate(opts.startDate, -30).replace(/\//g, "-"));
@@ -114991,31 +115301,74 @@ async function runAdPmaxAssetsUpdate(opts) {
114991
115301
  async function runAdPmaxYoutubeLink(opts) {
114992
115302
  const accountId = requireAccountId(opts.account);
114993
115303
  const assetGroupId = opts.assetGroupId.trim();
114994
- let body;
114995
- if (opts.bodyFile) {
114996
- body = loadPmaxJsonFile(opts.bodyFile);
114997
- } else {
114998
- body = {
114999
- youtubeUrlOrId: opts.youtubeUrlOrId.trim()
115000
- };
115001
- if (opts.campaignId?.trim()) body["campaignId"] = opts.campaignId.trim();
115002
- if (opts.assetName?.trim()) body["assetName"] = opts.assetName.trim();
115304
+ const youtubeInput = opts.youtubeUrlOrId?.trim();
115305
+ const videoPathInput = opts.videoPath?.trim();
115306
+ if (!opts.bodyFile) {
115307
+ if (youtubeInput && videoPathInput) {
115308
+ console.error("\n\u274C --youtube \u4E0E --video-path \u53EA\u80FD\u4E8C\u9009\u4E00\n");
115309
+ process.exit(1);
115310
+ }
115311
+ if (!youtubeInput && !videoPathInput) {
115312
+ console.error("\n\u274C \u8BF7\u6307\u5B9A --youtube\u3001--video-path \u6216 --body-file\n");
115313
+ process.exit(1);
115314
+ }
115315
+ if (videoPathInput) {
115316
+ const pathErr = validatePmaxVideoPathQuick(resolvePmaxVideoAbsPath(videoPathInput));
115317
+ if (pathErr) {
115318
+ console.error(`
115319
+ \u274C ${pathErr}
115320
+ `);
115321
+ process.exit(1);
115322
+ }
115323
+ }
115003
115324
  }
115004
115325
  await withGoogleApi(opts, async (config, googleApiUrl) => {
115005
- const url = pmaxAssetGroupUrl(googleApiUrl, accountId, assetGroupId, "youtube");
115006
115326
  let data;
115327
+ let linkedTarget = youtubeInput;
115007
115328
  try {
115008
- const raw = await pmaxApiFetch(
115009
- url,
115010
- config,
115011
- { method: "POST", body: JSON.stringify(body) },
115012
- opts.verbose
115013
- );
115014
- data = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { data: raw };
115329
+ if (opts.bodyFile) {
115330
+ const url = pmaxAssetGroupUrl(googleApiUrl, accountId, assetGroupId, "youtube");
115331
+ const body = loadPmaxJsonFile(opts.bodyFile);
115332
+ linkedTarget = typeof body["youtubeUrlOrId"] === "string" ? body["youtubeUrlOrId"].trim() : linkedTarget;
115333
+ const raw = await pmaxApiFetch(
115334
+ url,
115335
+ config,
115336
+ { method: "POST", body: JSON.stringify(body) },
115337
+ opts.verbose
115338
+ );
115339
+ data = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { data: raw };
115340
+ } else {
115341
+ if (videoPathInput) {
115342
+ const absVideo = resolvePmaxVideoAbsPath(videoPathInput);
115343
+ if (!opts.jsonOut) console.log(`
115344
+ \u{1F4E4} \u4E0A\u4F20\u672C\u5730\u89C6\u9891\uFF08PyAPI\uFF09\u2026`);
115345
+ linkedTarget = await uploadPmaxLocalVideo({
115346
+ config,
115347
+ googleApiUrl,
115348
+ accountId,
115349
+ absVideoPath: absVideo,
115350
+ title: opts.videoTitle,
115351
+ description: opts.videoDescription,
115352
+ verbose: opts.verbose,
115353
+ onProgress: opts.jsonOut ? void 0 : (msg) => console.log(` ${msg}`)
115354
+ });
115355
+ if (!opts.jsonOut) console.log(` video_id\uFF1A${linkedTarget}`);
115356
+ }
115357
+ data = await postPmaxYoutubeLink({
115358
+ config,
115359
+ googleApiUrl,
115360
+ accountId,
115361
+ assetGroupId,
115362
+ youtubeUrlOrId: linkedTarget,
115363
+ campaignId: opts.campaignId,
115364
+ assetName: opts.assetName,
115365
+ verbose: opts.verbose
115366
+ });
115367
+ }
115015
115368
  } catch (err) {
115016
115369
  console.error(
115017
115370
  `
115018
- \u274C \u94FE\u63A5 YouTube \u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
115371
+ \u274C \u94FE\u63A5/\u66FF\u6362 YouTube \u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
115019
115372
  `
115020
115373
  );
115021
115374
  process.exit(1);
@@ -115030,7 +115383,8 @@ async function runAdPmaxYoutubeLink(opts) {
115030
115383
  return;
115031
115384
  }
115032
115385
  console.log(`
115033
- \u2705 \u5DF2\u94FE\u63A5 YouTube \u5230\u8D44\u4EA7\u7EC4 ${assetGroupId}`);
115386
+ \u2705 \u5DF2\u94FE\u63A5/\u66FF\u6362 YouTube \u5230\u8D44\u4EA7\u7EC4 ${assetGroupId}`);
115387
+ if (linkedTarget) console.log(` youtube\uFF1A${linkedTarget}`);
115034
115388
  console.log(` assetResourceName\uFF1A${data["assetResourceName"] ?? "\u2014"}
115035
115389
  `);
115036
115390
  });
@@ -115202,8 +115556,8 @@ async function runAdPmaxImageUpload(opts) {
115202
115556
  let data;
115203
115557
  try {
115204
115558
  if (opts.imagePath?.trim()) {
115205
- const abs = isAbsolute5(opts.imagePath) ? opts.imagePath.trim() : resolvePmaxImagePath2(process.cwd(), opts.imagePath);
115206
- const name2 = (opts.name ?? basename5(abs)).trim();
115559
+ const abs = isAbsolute6(opts.imagePath) ? opts.imagePath.trim() : resolvePmaxImagePath2(process.cwd(), opts.imagePath);
115560
+ const name2 = (opts.name ?? basename6(abs)).trim();
115207
115561
  const id = await uploadPmaxImageFile(
115208
115562
  config,
115209
115563
  googleApiUrl,
@@ -115391,8 +115745,8 @@ async function runAdCampaignValidate(opts) {
115391
115745
  }
115392
115746
 
115393
115747
  // src/commands/ad/pmax-image-convert.ts
115394
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5, readFileSync as readFileSync8, existsSync as existsSync3 } from "fs";
115395
- import { resolve as resolve9, dirname as dirname10, basename as basename6, extname } from "path";
115748
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5, readFileSync as readFileSync9, existsSync as existsSync4 } from "fs";
115749
+ import { resolve as resolve10, dirname as dirname11, basename as basename7, extname } from "path";
115396
115750
  var SPECS = [
115397
115751
  { kind: "marketing", width: 1200, height: 628, fit: "cover", suffix: "_marketing" },
115398
115752
  { kind: "square", width: 1200, height: 1200, fit: "cover", suffix: "_square" },
@@ -115439,9 +115793,9 @@ async function runAdPmaxImageConvert(opts) {
115439
115793
  (s) => s.kind === "logo" && opts.logoUseCover ? { ...s, fit: "cover" } : s
115440
115794
  );
115441
115795
  const firstInput = input ?? inputMarketing ?? inputSquare ?? inputLogo;
115442
- const outputDir = opts.outputDir ? resolve9(opts.outputDir) : dirname10(resolve9(firstInput));
115796
+ const outputDir = opts.outputDir ? resolve10(opts.outputDir) : dirname11(resolve10(firstInput));
115443
115797
  mkdirSync3(outputDir, { recursive: true });
115444
- const prefix = opts.prefix ?? basename6(firstInput, extname(firstInput));
115798
+ const prefix = opts.prefix ?? basename7(firstInput, extname(firstInput));
115445
115799
  const quality = opts.quality ?? 85;
115446
115800
  const sharpMod = await loadSharp();
115447
115801
  const sharpFn = (p) => sharpMod(p);
@@ -115453,15 +115807,15 @@ async function runAdPmaxImageConvert(opts) {
115453
115807
  console.warn(`\u26A0\uFE0F ${spec.kind}\uFF1A\u672A\u6307\u5B9A\u8F93\u5165\u56FE\uFF0C\u8DF3\u8FC7`);
115454
115808
  continue;
115455
115809
  }
115456
- const absInput = resolve9(src);
115457
- if (!existsSync3(absInput)) {
115810
+ const absInput = resolve10(src);
115811
+ if (!existsSync4(absInput)) {
115458
115812
  console.error(`\u274C \u8F93\u5165\u6587\u4EF6\u4E0D\u5B58\u5728\uFF1A${absInput}`);
115459
115813
  hasError = true;
115460
115814
  continue;
115461
115815
  }
115462
115816
  const outExt = extname(absInput).toLowerCase() === ".png" ? ".png" : ".jpg";
115463
115817
  const outName = `${prefix}${spec.suffix}${outExt}`;
115464
- const absOutput = resolve9(outputDir, outName);
115818
+ const absOutput = resolve10(outputDir, outName);
115465
115819
  try {
115466
115820
  await convertOne(sharpFn, absInput, absOutput, spec, quality);
115467
115821
  const sizeKB = Math.ceil(
@@ -115480,9 +115834,9 @@ async function runAdPmaxImageConvert(opts) {
115480
115834
  process.exit(1);
115481
115835
  }
115482
115836
  if (opts.updateConfig) {
115483
- const configPath = resolve9(opts.updateConfig);
115837
+ const configPath = resolve10(opts.updateConfig);
115484
115838
  try {
115485
- const raw = JSON.parse(readFileSync8(configPath, "utf8"));
115839
+ const raw = JSON.parse(readFileSync9(configPath, "utf8"));
115486
115840
  const imgPaths = raw["imagePaths"] ?? {};
115487
115841
  if (outputPaths.marketing) imgPaths["marketing"] = outputPaths.marketing;
115488
115842
  if (outputPaths.square) imgPaths["square"] = outputPaths.square;
@@ -115891,7 +116245,7 @@ function register20(program2) {
115891
116245
  }
115892
116246
  );
115893
116247
  adCmd.command("pmax-create").description(
115894
- '\u65B0\u5EFA Performance Max \u5E7F\u544A\u7CFB\u5217\uFF08\u540C\u6B65 API\uFF1B\u4EC5\u652F\u6301 JSON \u914D\u7F6E\u6587\u4EF6\uFF09\n\n \u7528\u6CD5\uFF1A\n 1. \u590D\u5236 pmax-create-template.json\uFF0C\u5B57\u6BB5\u8BF4\u660E\u89C1 pmax-create-template.md\n 2. siluzan-tso ad geo search -a <accountId> -q "United States"\n 3. siluzan-tso ad pmax-validate --config-file ./pmax.json\n 4. siluzan-tso ad pmax-create --config-file ./pmax.json # imagePaths \u81EA\u52A8\u4E0A\u4F20\n 5. siluzan-tso ad campaigns -a <accountId> --json-out # \u786E\u8BA4 PERFORMANCE_MAX'
116248
+ '\u65B0\u5EFA Performance Max \u5E7F\u544A\u7CFB\u5217\uFF08\u540C\u6B65 API\uFF1B\u4EC5\u652F\u6301 JSON \u914D\u7F6E\u6587\u4EF6\uFF09\n\n \u7528\u6CD5\uFF1A\n 1. \u590D\u5236 pmax-create-template.json\uFF0C\u5B57\u6BB5\u8BF4\u660E\u89C1 pmax-create-template.md\n 2. siluzan-tso ad geo search -a <accountId> -q "United States"\n 3. siluzan-tso ad pmax-validate --config-file ./pmax.json\n 4. siluzan-tso ad pmax-create --config-file ./pmax.json # imagePaths \u81EA\u52A8\u4E0A\u4F20\uFF1BvideoPath \u6216 youtubeUrlOrId \u94FE\u63A5\u89C6\u9891\n 5. siluzan-tso ad campaigns -a <accountId> --json-out # \u786E\u8BA4 PERFORMANCE_MAX\n \u89C6\u9891\uFF1AvideoPath\uFF08PyAPI \u4E0A\u4F20\uFF09\u6216 youtubeUrlOrId\uFF1B\u6216\u521B\u5EFA\u540E pmax-youtube-link'
115895
116249
  ).requiredOption(
115896
116250
  "--config-file <path>",
115897
116251
  "JSON \u914D\u7F6E\u6587\u4EF6\uFF08\u6A21\u677F\u89C1 assets/siluzan-ads/assets/pmax-create-*.json|md\uFF09"
@@ -115975,18 +116329,19 @@ function register20(program2) {
115975
116329
  }
115976
116330
  );
115977
116331
  addPmaxJsonOptions(
115978
- adCmd.command("pmax-youtube-link").description("\u4E3A\u8D44\u4EA7\u7EC4\u94FE\u63A5 YouTube \u89C6\u9891\uFF08POST .../youtube\uFF09").requiredOption("-a, --account <id>", "Google \u5A92\u4F53\u5BA2\u6237 ID").requiredOption("--asset-group-id <id>", "\u8D44\u4EA7\u7EC4 ID").option("--campaign-id <id>", "\u6240\u5C5E\u6D3B\u52A8 ID\uFF08\u5EFA\u8BAE\u4F20\uFF09").option("--youtube <urlOrId>", "YouTube URL \u6216 11 \u4F4D\u89C6\u9891 ID").option("--asset-name <name>", "\u8D44\u4EA7\u663E\u793A\u540D").option("--body-file <path>", "\u5B8C\u6574 JSON body\uFF08\u8986\u76D6 --youtube\uFF09").option("-t, --token <token>", "Auth Token")
116332
+ adCmd.command("pmax-youtube-link").description(
116333
+ "\u4E3A\u8D44\u4EA7\u7EC4\u94FE\u63A5\u6216\u66FF\u6362 YouTube \u89C6\u9891\uFF08POST .../youtube\uFF1B\u4F1A\u5148\u79FB\u9664\u5DF2\u6709 YOUTUBE_VIDEO \u518D\u6302\u65B0\u7247\uFF09\n --youtube\uFF1A\u5DF2\u6709 URL/ID\uFF1B--video-path\uFF1A\u672C\u5730\u6587\u4EF6\u7ECF PyAPI \u4E0A\u4F20\u540E\u94FE\u63A5"
116334
+ ).requiredOption("-a, --account <id>", "Google \u5A92\u4F53\u5BA2\u6237 ID").requiredOption("--asset-group-id <id>", "\u8D44\u4EA7\u7EC4 ID").option("--campaign-id <id>", "\u6240\u5C5E\u6D3B\u52A8 ID\uFF08\u5EFA\u8BAE\u4F20\uFF09").option("--youtube <urlOrId>", "YouTube URL \u6216 11 \u4F4D\u89C6\u9891 ID\uFF08\u4E0E --video-path \u4E8C\u9009\u4E00\uFF09").option("--video-path <path>", "\u672C\u5730\u89C6\u9891\u8DEF\u5F84\uFF08PyAPI \u4E0A\u4F20\uFF1B\u4E0E --youtube \u4E8C\u9009\u4E00\uFF09").option("--video-title <title>", "PyAPI \u4E0A\u4F20\u6807\u9898\uFF08\u9ED8\u8BA4\u6587\u4EF6\u540D\uFF09").option("--video-description <text>", "PyAPI \u4E0A\u4F20\u63CF\u8FF0").option("--asset-name <name>", "\u8D44\u4EA7\u663E\u793A\u540D").option("--body-file <path>", "\u5B8C\u6574 JSON body\uFF08\u542B youtubeUrlOrId\uFF1B\u8986\u76D6 --youtube/--video-path\uFF09").option("-t, --token <token>", "Auth Token")
115979
116335
  ).action(
115980
116336
  async (opts) => {
115981
- if (!opts.bodyFile && !opts.youtube?.trim()) {
115982
- console.error("\n\u274C \u8BF7\u6307\u5B9A --youtube \u6216 --body-file\n");
115983
- process.exit(1);
115984
- }
115985
116337
  await runAdPmaxYoutubeLink({
115986
116338
  account: opts.account,
115987
116339
  assetGroupId: opts.assetGroupId,
115988
116340
  campaignId: opts.campaignId,
115989
- youtubeUrlOrId: opts.youtube ?? "",
116341
+ youtubeUrlOrId: opts.youtube,
116342
+ videoPath: opts.videoPath,
116343
+ videoTitle: opts.videoTitle,
116344
+ videoDescription: opts.videoDescription,
115990
116345
  assetName: opts.assetName,
115991
116346
  bodyFile: opts.bodyFile,
115992
116347
  token: opts.token,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.22-beta.12",
4
- "publishedAt": 1779959114618
3
+ "version": "1.1.22-beta.15",
4
+ "publishedAt": 1779965706071
5
5
  }
@@ -16,6 +16,7 @@
16
16
  | 文案超长 | `pmax-validate --json-out` 读 `lengthViolations`(含完整 `text`);**勿自动截断**,列改写方案给用户确认后再改 JSON 并重跑 validate(同 Search `campaign-validate`) |
17
17
  | 金额 | JSON 填**主币种「元」**;CLI 提交前 `budget`、`targetCpa_BidingAmount` ×100 |
18
18
  | 图片 | **只填 `imagePaths`** 指向本地 PNG/JPEG;`pmax-create` 会自动 multipart 上传并用 assetId 创建(勿把 Base64 提交进 Git) |
19
+ | 视频 | 创建:`videoPath` / `youtubeUrlOrId`(二选一,`pmax-create` 后自动链接)。**换片**:`ad pmax-youtube-link --youtube …` 或 `--video-path …`(会替换资产组内已有 YouTube 视频) |
19
20
  | 改已上线 PMax | `ad pmax-get` / `pmax-edit` / `pmax-assets-update` 等(见 `references/google-ads/pmax-api.md`) |
20
21
  | 列表复核 | `ad campaigns -a <id> --json-out ./snap`,`channelTypeV2` 应为 `PERFORMANCE_MAX` |
21
22
 
@@ -61,6 +62,11 @@ siluzan-tso ad campaigns -a <accountId> --json-out ./snap
61
62
  | `biddingStrategyTypeV2` | string | | 默认 `MAXIMIZE_CONVERSIONS` |
62
63
  | `targetCpa_BidingAmount` | number | | `TARGET_CPA` 或带目标 CPA 的 `MAXIMIZE_CONVERSIONS` 时必填(**元**) |
63
64
  | `targetRoas` | number | | `TARGET_ROAS` 时必填(如 `2.5` = 250%) |
65
+ | `videoPath` | string | | 本地视频路径(`.mp4` / `.mov` 等);经 PyAPI 上传后自动链接(与 `youtubeUrlOrId` 二选一) |
66
+ | `videoTitle` | string | | PyAPI 上传标题(默认文件名) |
67
+ | `videoDescription` | string | | PyAPI 上传描述(可选) |
68
+ | `youtubeUrlOrId` | string | | 已有 YouTube URL 或 11 位 ID;`pmax-create` 后自动链接 |
69
+ | `youtubeAssetName` | string | | 链接 YouTube 时的资产显示名 |
64
70
 
65
71
  \* 三张图各须有一种来源(路径或 Base64)。
66
72
 
@@ -48,6 +48,8 @@
48
48
 
49
49
  **图片**:配置 `imagePaths`(相对 JSON 文件目录)或 `marketingImageBase64` / `squareMarketingImageBase64` / `logoImageBase64`。`pmax-validate` 会自动校验图片尺寸(最小值 / 推荐值 / 宽高比 ±2% / 文件大小 ≤5120 KB)。如需将任意图片转为合规素材,用 `ad pmax-image-convert`(`marketing` / `square` / `logo` 三种格式,sharp 处理,居中裁切)。
50
50
 
51
+ **视频**:`videoPath`(本地文件,经 `{googleApiUrl}/pyapi` 上传并轮询 `video_id` 后自动链接)与 `youtubeUrlOrId` **二选一**;或创建后 `ad pmax-youtube-link`。
52
+
51
53
  **禁止**:对已创建的 PMax 系列使用 `ad campaign-edit`(旧 PUT 会 **400**)。
52
54
 
53
55
  ### 已上线 PMax 管理(CLI)
@@ -60,7 +62,7 @@
60
62
  | 改资产组 | `ad pmax-asset-group-edit` |
61
63
  | 删资产组 | `ad pmax-asset-group-edit --status REMOVED`(软删;网关无 DELETE 端点) |
62
64
  | 改资产 | `ad pmax-assets-update --config-file …` |
63
- | YouTube | `ad pmax-youtube-link` |
65
+ | YouTube 链接/替换 | `ad pmax-youtube-link`(`--youtube` 或 `--video-path`) |
64
66
  | 信号 | `ad pmax-signals-get` / `ad pmax-signals-set`;受众下拉 `ad pmax-audiences` |
65
67
  | 图片库 | `ad pmax-image-upload` |
66
68
  | 报表 | `ad pmax-report-asset-groups` / `ad pmax-report-geo` |
@@ -23,7 +23,7 @@
23
23
  | `ad pmax-asset-group-create` | 新建资产组 |
24
24
  | `ad pmax-asset-group-edit` | 编辑资产组 |
25
25
  | `ad pmax-assets-update` | 更新资产 |
26
- | `ad pmax-youtube-link` | 关联 YouTube |
26
+ | `ad pmax-youtube-link` | 关联/替换 YouTube(会移除资产组内已有视频再挂新片) |
27
27
  | `ad pmax-signals-get` / `ad pmax-signals-set` | 读/写信号(**全量** PUT) |
28
28
  | `ad pmax-audiences` | 受众列表 |
29
29
  | `ad pmax-image-upload` | 单张图片上传 |
@@ -38,6 +38,14 @@
38
38
  2. `ad pmax-validate` → 用户确认 → `ad pmax-create`:CLI **自动上传**三张图并写入 `*ImageAssetId`(Body 不含 Base64)。
39
39
  3. 可选预上传:`ad pmax-image-upload -a <id> --image-path ./x.png`,将返回 `id` 写入 JSON。
40
40
 
41
+ ### 视频
42
+
43
+ - **`videoPath`**:本地 `.mp4` 等;`pmax-create` 经 **GoogleAdsPyAPI** `POST {googleApiUrl}/pyapi/video/upload` 上传,轮询 `GET .../upload/status` 得 `video_id`,再 `POST .../asset-group/{id}/youtube` 链接(与 `youtubeUrlOrId` 二选一)。
44
+ - **`youtubeUrlOrId`**:已有 YouTube URL 或 11 位 ID;创建成功后自动链接。
45
+ - **已创建系列换视频**:`ad pmax-youtube-link`(同一接口;后端先删旧 `YOUTUBE_VIDEO` 再链接新片):
46
+ - `--youtube "<url或ID>"` 或 `--video-path ./new.mp4`(可选 `--video-title`)
47
+ - 示例:`siluzan-tso ad pmax-youtube-link -a <id> --asset-group-id <agId> --campaign-id <cid> --video-path ./promo-v2.mp4`
48
+
41
49
  ---
42
50
 
43
51
  ## 编辑流程
@@ -9,7 +9,7 @@ $ErrorActionPreference = 'Stop'
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  $PKG_NAME = 'siluzan-tso-cli'
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- $PKG_VERSION = '1.1.22-beta.12'
12
+ $PKG_VERSION = '1.1.22-beta.15'
13
13
  $CLI_BIN = 'siluzan-tso'
14
14
  $SKILL_LABEL = 'Siluzan TSO'
15
15
  $INSTALL_CMD = 'npm install -g siluzan-tso-cli@beta'
@@ -9,7 +9,7 @@ set -euo pipefail
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  readonly PKG_NAME="siluzan-tso-cli"
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- readonly PKG_VERSION="1.1.22-beta.12"
12
+ readonly PKG_VERSION="1.1.22-beta.15"
13
13
  readonly CLI_BIN="siluzan-tso"
14
14
  readonly SKILL_LABEL="Siluzan TSO"
15
15
  readonly INSTALL_CMD="npm install -g siluzan-tso-cli@beta"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siluzan-tso-cli",
3
- "version": "1.1.22-beta.12",
3
+ "version": "1.1.22-beta.15",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",