siluzan-tso-cli 1.1.17-beta.2 → 1.1.18-beta.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
@@ -51,7 +51,7 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
51
51
  siluzan-tso init --force # 强制覆盖已存在文件
52
52
  ```
53
53
 
54
- > **注意**:当前为测试版(1.1.17-beta.2),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.18-beta.1),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -2054,12 +2054,12 @@ function validateBaseUrl(raw) {
2054
2054
  if (url.protocol !== "https:") {
2055
2055
  return `\u5FC5\u987B\u4F7F\u7528 HTTPS\uFF0C\u5F53\u524D\u534F\u8BAE\uFF1A${url.protocol}`;
2056
2056
  }
2057
- const hostname = url.hostname.toLowerCase();
2057
+ const hostname2 = url.hostname.toLowerCase();
2058
2058
  const ok = ALLOWED_HOSTNAME_SUFFIXES.some(
2059
- (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`)
2059
+ (suffix) => hostname2 === suffix || hostname2.endsWith(`.${suffix}`)
2060
2060
  );
2061
2061
  if (!ok) {
2062
- return `\u4E3B\u673A\u540D "${hostname}" \u4E0D\u5728\u5141\u8BB8\u5217\u8868\uFF08${ALLOWED_HOSTNAME_SUFFIXES.join("\u3001")}\uFF09\u5185\u3002
2062
+ return `\u4E3B\u673A\u540D "${hostname2}" \u4E0D\u5728\u5141\u8BB8\u5217\u8868\uFF08${ALLOWED_HOSTNAME_SUFFIXES.join("\u3001")}\uFF09\u5185\u3002
2063
2063
  \u5982\u9700\u8FDE\u63A5\u81EA\u5B9A\u4E49\u90E8\u7F72\u7AEF\u70B9\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u6DFB\u52A0\u767D\u540D\u5355\u3002`;
2064
2064
  }
2065
2065
  return null;
@@ -2735,7 +2735,215 @@ async function readPreSnapshotPayload(namespace, preSnapshotRef) {
2735
2735
  return null;
2736
2736
  }
2737
2737
  }
2738
- var import_semver, SILUZAN_DIR, CONFIG_FILE, ALLOWED_HOSTNAME_SUFFIXES, DEFAULT_TIMEOUT_MS, MAX_RESPONSE_BYTES, httpsAgent, httpAgent, PERF_PREFIX, MAX_INVOCATION_CHARS, currentInvocation, MAX_COMMIT_CHARS, cliCommitOverride, WRITE_AUDIT_SCHEMA_VERSION, MUTATING, BEIJING_TZ, AUDIT_LOG_FILENAME_RE, DEFAULT_MAX_FILE_MB, MIN_SEGMENT_BYTES, MAX_SEGMENT_BYTES, DEFAULT_RETENTION_DAYS, MIN_RETENTION_DAYS, MAX_RETENTION_DAYS, PRUNE_THROTTLE_MS, lastPruneAtMs, MAX_BODY_CHARS, MAX_ERROR_CHARS, DEFAULT_FIND_AUDIT_MAX_DAYS, MAX_SNAPSHOT_FILE_BYTES;
2738
+ function deriveSsoBaseUrl(anyApiBase) {
2739
+ try {
2740
+ const u = new URL(anyApiBase);
2741
+ u.hostname = u.hostname.replace(/^(tso-api|cso|api)/, "sso");
2742
+ return u.origin;
2743
+ } catch {
2744
+ return "https://sso.siluzan.com";
2745
+ }
2746
+ }
2747
+ function deriveCsoApiBaseUrl(anyApiBase) {
2748
+ try {
2749
+ const u = new URL(anyApiBase);
2750
+ u.hostname = u.hostname.replace(/^(tso-api|api)/, "cso");
2751
+ return u.origin;
2752
+ } catch {
2753
+ return "https://cso.siluzan.com";
2754
+ }
2755
+ }
2756
+ function normalizeChinaPhone(input) {
2757
+ if (!input) return input;
2758
+ const cleaned = input.replace(/[\s\-()\u00A0]/g, "");
2759
+ if (/^\+861\d{10}$/.test(cleaned)) {
2760
+ return cleaned;
2761
+ }
2762
+ if (/^861\d{10}$/.test(cleaned)) {
2763
+ return `+${cleaned}`;
2764
+ }
2765
+ if (/^00861\d{10}$/.test(cleaned)) {
2766
+ return `+${cleaned.slice(2)}`;
2767
+ }
2768
+ if (/^1\d{10}$/.test(cleaned)) {
2769
+ return `+86${cleaned}`;
2770
+ }
2771
+ return cleaned;
2772
+ }
2773
+ function isValidChinaPhone(input) {
2774
+ return /^\+861\d{10}$/.test(normalizeChinaPhone(input));
2775
+ }
2776
+ async function sendPhoneLoginCode(opts) {
2777
+ const phone = normalizeChinaPhone(opts.phone);
2778
+ const url = `${opts.ssoBaseUrl}/Account/SendVaildCode?Phone=${encodeURIComponent(
2779
+ phone
2780
+ )}&RandStr=&Iicket=`;
2781
+ if (opts.verbose) {
2782
+ process.stderr.write(`[phone-login] GET ${url}
2783
+ `);
2784
+ }
2785
+ const res = await rawRequest(url, {
2786
+ method: "GET",
2787
+ headers: {
2788
+ Accept: "application/json",
2789
+ "Accept-Language": "zh-CN"
2790
+ }
2791
+ });
2792
+ if (res.status < 200 || res.status >= 300) {
2793
+ return { ok: false, message: `HTTP ${res.status}` };
2794
+ }
2795
+ let body;
2796
+ try {
2797
+ body = JSON.parse(res.text);
2798
+ } catch {
2799
+ return { ok: false, message: `\u54CD\u5E94\u975E JSON\uFF1A${res.text.slice(0, 120)}` };
2800
+ }
2801
+ const state = (body.State ?? body.state ?? "").toLowerCase();
2802
+ const message = body.Message ?? body.message ?? "";
2803
+ return { ok: state === "ok", message };
2804
+ }
2805
+ async function loginByPhoneCode(opts) {
2806
+ const phone = normalizeChinaPhone(opts.phone);
2807
+ const url = `${opts.ssoBaseUrl}/Account/LoginByMiniCode?phone=${encodeURIComponent(
2808
+ phone
2809
+ )}&code=${encodeURIComponent(opts.code)}&key=`;
2810
+ if (opts.verbose) {
2811
+ process.stderr.write(`[phone-login] GET ${url}
2812
+ `);
2813
+ }
2814
+ const res = await rawRequest(url, {
2815
+ method: "GET",
2816
+ headers: {
2817
+ Accept: "application/json",
2818
+ "Accept-Language": "zh-CN"
2819
+ }
2820
+ });
2821
+ if (opts.verbose) {
2822
+ process.stderr.write(
2823
+ `[phone-login] LoginByMiniCode HTTP ${res.status} body=${res.text.slice(0, 500)}
2824
+ `
2825
+ );
2826
+ }
2827
+ if (res.status < 200 || res.status >= 300) {
2828
+ throw new Error(`\u767B\u5F55\u5931\u8D25\uFF1AHTTP ${res.status}`);
2829
+ }
2830
+ let body;
2831
+ try {
2832
+ body = JSON.parse(res.text);
2833
+ } catch {
2834
+ throw new Error(`\u767B\u5F55\u54CD\u5E94\u975E JSON\uFF1A${res.text.slice(0, 120)}`);
2835
+ }
2836
+ const errMsg = body.msg ?? body.Msg ?? "";
2837
+ if (!body.token || typeof body.token === "string") {
2838
+ throw new Error(errMsg || "\u767B\u5F55\u5931\u8D25\uFF08\u540E\u7AEF\u672A\u8FD4\u56DE\u539F\u56E0\uFF09");
2839
+ }
2840
+ const token = body.token;
2841
+ const isError = token.isError ?? token.IsError ?? false;
2842
+ if (isError) {
2843
+ const err = token.errorDescription ?? token.ErrorDescription ?? token.error ?? token.Error ?? "\u672A\u77E5\u9519\u8BEF";
2844
+ throw new Error(`OAuth \u5931\u8D25\uFF1A${err}`);
2845
+ }
2846
+ let accessToken = token.accessToken ?? token.access_token ?? "";
2847
+ let tokenType = token.tokenType ?? token.token_type ?? "Bearer";
2848
+ let expiresIn = token.expiresIn ?? token.expires_in;
2849
+ if (!accessToken) {
2850
+ const rawJson = token.raw ?? token.Raw;
2851
+ if (rawJson && typeof rawJson === "string") {
2852
+ try {
2853
+ const parsed = JSON.parse(rawJson);
2854
+ accessToken = parsed.access_token ?? "";
2855
+ tokenType = parsed.token_type ?? tokenType;
2856
+ expiresIn = parsed.expires_in ?? expiresIn;
2857
+ } catch {
2858
+ }
2859
+ }
2860
+ }
2861
+ if (!accessToken) {
2862
+ throw new Error(
2863
+ `\u767B\u5F55\u54CD\u5E94\u7F3A\u5C11 access_token\uFF08\u54CD\u5E94\u5B57\u6BB5\uFF1A${Object.keys(token).join(", ") || "\u65E0"}\uFF09`
2864
+ );
2865
+ }
2866
+ return { accessToken, tokenType, expiresIn };
2867
+ }
2868
+ async function createApiKeyByBearer(opts) {
2869
+ if (opts.allowedServices.length === 0) {
2870
+ throw new Error("createApiKey \u81F3\u5C11\u9700\u8981\u4F20\u5165\u4E00\u4E2A allowedServices");
2871
+ }
2872
+ if (opts.validDays === void 0 && opts.expiresAt === void 0) {
2873
+ throw new Error("createApiKey \u5FC5\u987B\u6307\u5B9A validDays \u6216 expiresAt");
2874
+ }
2875
+ const body = JSON.stringify({
2876
+ name: opts.name,
2877
+ validDays: opts.validDays,
2878
+ expiresAt: opts.expiresAt,
2879
+ allowedServices: opts.allowedServices.map((s) => API_KEY_SERVICE_VALUES[s])
2880
+ });
2881
+ const url = `${opts.csoBaseUrl}/cso/v1/apikey`;
2882
+ if (opts.verbose) {
2883
+ process.stderr.write(`[phone-login] POST ${url} body=${body}
2884
+ `);
2885
+ }
2886
+ const res = await rawRequest(url, {
2887
+ method: "POST",
2888
+ headers: {
2889
+ "Content-Type": "application/json",
2890
+ Accept: "application/json",
2891
+ "Accept-Language": "zh-CN",
2892
+ Authorization: `Bearer ${opts.bearerToken}`,
2893
+ "Content-Length": String(Buffer.byteLength(body, "utf8"))
2894
+ },
2895
+ body
2896
+ });
2897
+ if (res.status < 200 || res.status >= 300) {
2898
+ throw new Error(`\u521B\u5EFA API Key \u5931\u8D25\uFF1AHTTP ${res.status}\uFF08${res.text.slice(0, 200)}\uFF09`);
2899
+ }
2900
+ let json;
2901
+ try {
2902
+ json = JSON.parse(res.text);
2903
+ } catch {
2904
+ throw new Error(`\u521B\u5EFA API Key \u54CD\u5E94\u975E JSON\uFF1A${res.text.slice(0, 120)}`);
2905
+ }
2906
+ const code = json.code ?? json.Code;
2907
+ if (code !== 1) {
2908
+ throw new Error(`\u521B\u5EFA API Key \u5931\u8D25\uFF1A${json.message ?? json.Message ?? "\u672A\u77E5\u9519\u8BEF"}`);
2909
+ }
2910
+ const data = json.data ?? json.Data;
2911
+ if (!data?.rawKey) {
2912
+ throw new Error("\u521B\u5EFA API Key \u54CD\u5E94\u7F3A\u5C11 rawKey \u5B57\u6BB5");
2913
+ }
2914
+ return data;
2915
+ }
2916
+ async function runPhoneLoginAndIssueApiKey(opts) {
2917
+ const sendResult = await sendPhoneLoginCode({
2918
+ ssoBaseUrl: opts.ssoBaseUrl,
2919
+ phone: opts.phone,
2920
+ verbose: opts.verbose
2921
+ });
2922
+ if (!sendResult.ok) {
2923
+ throw new Error(`\u77ED\u4FE1\u9A8C\u8BC1\u7801\u53D1\u9001\u5931\u8D25\uFF1A${sendResult.message || "(\u540E\u7AEF\u672A\u8FD4\u56DE\u539F\u56E0)"}`);
2924
+ }
2925
+ const code = await opts.getCode();
2926
+ if (!code) {
2927
+ throw new Error("\u5DF2\u53D6\u6D88\uFF08\u672A\u8F93\u5165\u9A8C\u8BC1\u7801\uFF09");
2928
+ }
2929
+ const tokenInfo = await loginByPhoneCode({
2930
+ ssoBaseUrl: opts.ssoBaseUrl,
2931
+ phone: opts.phone,
2932
+ code: code.trim(),
2933
+ verbose: opts.verbose
2934
+ });
2935
+ const apiKey = await createApiKeyByBearer({
2936
+ csoBaseUrl: opts.csoBaseUrl,
2937
+ bearerToken: tokenInfo.accessToken,
2938
+ name: opts.apiKeyName,
2939
+ validDays: opts.validDays ?? (opts.expiresAt ? void 0 : 90),
2940
+ expiresAt: opts.expiresAt,
2941
+ allowedServices: opts.allowedServices,
2942
+ verbose: opts.verbose
2943
+ });
2944
+ return apiKey;
2945
+ }
2946
+ var import_semver, SILUZAN_DIR, CONFIG_FILE, ALLOWED_HOSTNAME_SUFFIXES, DEFAULT_TIMEOUT_MS, MAX_RESPONSE_BYTES, httpsAgent, httpAgent, PERF_PREFIX, MAX_INVOCATION_CHARS, currentInvocation, MAX_COMMIT_CHARS, cliCommitOverride, WRITE_AUDIT_SCHEMA_VERSION, MUTATING, BEIJING_TZ, AUDIT_LOG_FILENAME_RE, DEFAULT_MAX_FILE_MB, MIN_SEGMENT_BYTES, MAX_SEGMENT_BYTES, DEFAULT_RETENTION_DAYS, MIN_RETENTION_DAYS, MAX_RETENTION_DAYS, PRUNE_THROTTLE_MS, lastPruneAtMs, MAX_BODY_CHARS, MAX_ERROR_CHARS, DEFAULT_FIND_AUDIT_MAX_DAYS, MAX_SNAPSHOT_FILE_BYTES, API_KEY_SERVICE_VALUES;
2739
2947
  var init_dist = __esm({
2740
2948
  "../common/dist/index.js"() {
2741
2949
  "use strict";
@@ -2783,6 +2991,11 @@ var init_dist = __esm({
2783
2991
  MAX_ERROR_CHARS = 800;
2784
2992
  DEFAULT_FIND_AUDIT_MAX_DAYS = 400;
2785
2993
  MAX_SNAPSHOT_FILE_BYTES = 2 * 1024 * 1024;
2994
+ API_KEY_SERVICE_VALUES = {
2995
+ CSO: 0,
2996
+ TSO: 1,
2997
+ CUT: 2
2998
+ };
2786
2999
  }
2787
3000
  });
2788
3001
 
@@ -3842,18 +4055,78 @@ async function fetchBalanceMap(media, accountIds, config, startDate, endDate, ve
3842
4055
  }
3843
4056
  return result;
3844
4057
  }
4058
+ function parseGoogleAccountSpendOverviewRows(raw) {
4059
+ const fromDbItem = (it) => {
4060
+ if (it == null || it.mediaAccountId == null) return null;
4061
+ return {
4062
+ mode: "database",
4063
+ mediaAccountId: String(it.mediaAccountId),
4064
+ mediaCustomerName: it.mediaCustomerName ?? void 0,
4065
+ spend: typeof it.spend === "number" ? it.spend : void 0,
4066
+ impressions: typeof it.impressions === "number" ? it.impressions : void 0,
4067
+ clicks: typeof it.clicks === "number" ? it.clicks : void 0,
4068
+ conversions: typeof it.conversions === "number" ? it.conversions : void 0,
4069
+ costPerClick: typeof it.costPerClick === "number" ? it.costPerClick : void 0,
4070
+ currencyCode: it.currencyCode ?? void 0,
4071
+ status: it.status ?? void 0,
4072
+ remainingAccountBudget: typeof it.remainingAccountBudget === "number" ? it.remainingAccountBudget : void 0
4073
+ };
4074
+ };
4075
+ if (Array.isArray(raw)) {
4076
+ return raw.map(fromDbItem).filter((row) => row !== null);
4077
+ }
4078
+ if (!raw || typeof raw !== "object") return [];
4079
+ const r = raw;
4080
+ if (r.mode === "googleCombined") {
4081
+ const accounts = r.accounts ?? {};
4082
+ const rows = [];
4083
+ for (const [id, item] of Object.entries(accounts)) {
4084
+ const data = item?.data;
4085
+ if (!data) continue;
4086
+ rows.push({
4087
+ mode: "googleCombined",
4088
+ mediaAccountId: String(id),
4089
+ spend: typeof data.spend === "number" ? data.spend : void 0,
4090
+ impressions: typeof data.impressions === "number" ? data.impressions : void 0,
4091
+ clicks: typeof data.clicks === "number" ? data.clicks : void 0,
4092
+ conversions: typeof data.conversions === "number" ? data.conversions : void 0,
4093
+ // 实时模式下 averageCpc 即点击均价(CPC)
4094
+ costPerClick: typeof data.averageCpc === "number" ? data.averageCpc : void 0
4095
+ });
4096
+ }
4097
+ return rows;
4098
+ }
4099
+ const items = Array.isArray(r.items) ? r.items : [];
4100
+ return items.map(fromDbItem).filter((row) => row !== null);
4101
+ }
3845
4102
  async function fetchOverviewMap(media, accountIds, config, startDate, endDate, verbose) {
3846
4103
  const result = /* @__PURE__ */ new Map();
3847
4104
  if (accountIds.length === 0 || media === "MetaAd") return result;
3848
4105
  const range = defaultDateRange();
3849
- const params = new URLSearchParams({
3850
- period: "true",
3851
- startDate: startDate ?? range.startDate,
3852
- endDate: endDate ?? range.endDate,
3853
- mediaCustomerIds: accountIds.join(",")
3854
- });
3855
- const url = `${config.apiBaseUrl}/report/media-account/${media}/accountsoverview?${params}`;
4106
+ const start = startDate ?? range.startDate;
4107
+ const end = endDate ?? range.endDate;
3856
4108
  try {
4109
+ if (media === "Google") {
4110
+ const params2 = new URLSearchParams({
4111
+ startDate: start,
4112
+ endDate: end,
4113
+ mediaCustomerIds: accountIds.join("|")
4114
+ });
4115
+ const url2 = `${config.apiBaseUrl}/report/media-account/google/account-spend-overview?${params2}`;
4116
+ const raw2 = await apiFetch2(url2, config, {}, verbose);
4117
+ const rows = parseGoogleAccountSpendOverviewRows(raw2);
4118
+ for (const row of rows) {
4119
+ result.set(row.mediaAccountId, row);
4120
+ }
4121
+ return result;
4122
+ }
4123
+ const params = new URLSearchParams({
4124
+ period: "true",
4125
+ startDate: start,
4126
+ endDate: end,
4127
+ mediaCustomerIds: accountIds.join(",")
4128
+ });
4129
+ const url = `${config.apiBaseUrl}/report/media-account/${media}/accountsoverview?${params}`;
3857
4130
  const raw = await apiFetch2(url, config, {}, verbose);
3858
4131
  const items = Array.isArray(raw) ? raw : [];
3859
4132
  for (const item of items) {
@@ -3861,7 +4134,13 @@ async function fetchOverviewMap(media, accountIds, config, startDate, endDate, v
3861
4134
  result.set(String(item.mediaAccountId), item);
3862
4135
  }
3863
4136
  }
3864
- } catch {
4137
+ } catch (err) {
4138
+ if (verbose) {
4139
+ process.stderr.write(
4140
+ `[fetchOverviewMap] \u5F02\u5E38\u88AB\u541E\uFF1A${err instanceof Error ? err.message : String(err)}
4141
+ `
4142
+ );
4143
+ }
3865
4144
  }
3866
4145
  return result;
3867
4146
  }
@@ -3941,24 +4220,36 @@ async function runAccountsDigest(opts) {
3941
4220
  const pageSize = Math.max(1, Math.min(500, opts.pageSize ?? 200));
3942
4221
  const maxPages = Math.max(1, Math.min(200, opts.maxPages ?? 20));
3943
4222
  let accountsList;
3944
- try {
3945
- const res = await fetchAccountsByMedia(media, config, {
3946
- pageSize,
3947
- maxPages,
3948
- verbose: opts.verbose
3949
- });
3950
- accountsList = res.items;
3951
- } catch (err) {
3952
- console.error(`
3953
- \u274C \u62C9\u53D6\u8D26\u6237\u6E05\u5355\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
3954
- `);
3955
- process.exit(1);
3956
- }
4223
+ let scannedFromList = false;
3957
4224
  if (filterSet.size > 0) {
3958
- accountsList = accountsList.filter((it) => {
3959
- const id = it.ma?.mediaCustomerId;
3960
- return id != null && filterSet.has(String(id));
3961
- });
4225
+ accountsList = filterIds.map((id) => ({
4226
+ ma: {
4227
+ entityId: "",
4228
+ mediaCustomerId: id,
4229
+ mediaCustomerName: null,
4230
+ mediaAccountType: media,
4231
+ invalidOAuthToken: false
4232
+ },
4233
+ accepted: true,
4234
+ mag: null
4235
+ }));
4236
+ } else {
4237
+ try {
4238
+ const res = await fetchAccountsByMedia(media, config, {
4239
+ pageSize,
4240
+ maxPages,
4241
+ verbose: opts.verbose
4242
+ });
4243
+ accountsList = res.items;
4244
+ scannedFromList = true;
4245
+ } catch (err) {
4246
+ console.error(
4247
+ `
4248
+ \u274C \u62C9\u53D6\u8D26\u6237\u6E05\u5355\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
4249
+ `
4250
+ );
4251
+ process.exit(1);
4252
+ }
3962
4253
  }
3963
4254
  const validIds = accountsList.filter((it) => it.ma?.mediaCustomerId && !it.ma?.invalidOAuthToken).map((it) => String(it.ma.mediaCustomerId));
3964
4255
  const CHUNK = 100;
@@ -4003,9 +4294,9 @@ async function runAccountsDigest(opts) {
4003
4294
  const cpa = conversions && conversions > 0 && spend != null ? spend / conversions : null;
4004
4295
  rows.push({
4005
4296
  mediaCustomerId: id,
4006
- name: ma.mediaCustomerName ?? null,
4297
+ name: ma.mediaCustomerName ?? bal?.name ?? ov?.mediaCustomerName ?? null,
4007
4298
  advertiserName: item.mag?.advertiserName ?? null,
4008
- currencyCode: bal?.currencyCode ?? ma.currencyCode ?? null,
4299
+ currencyCode: bal?.currencyCode ?? ma.currencyCode ?? ov?.currencyCode ?? null,
4009
4300
  balance: round2(bal?.remainingAccountBudget ?? null),
4010
4301
  spend: round2(spend),
4011
4302
  impressions: impressions != null ? Math.round(impressions) : null,
@@ -4037,6 +4328,8 @@ async function runAccountsDigest(opts) {
4037
4328
  note: opts.startDate && opts.endDate ? "\u7528\u6237/AI \u660E\u786E\u6307\u5B9A\u533A\u95F4" : "\u672A\u6307\u5B9A\u533A\u95F4\uFF0CCLI \u9ED8\u8BA4\u8FD1 7 \u5929\uFF1BSKILL \u8981\u6C42\u5148\u5411\u7528\u6237\u786E\u8BA4"
4038
4329
  },
4039
4330
  scanned: accountsList.length,
4331
+ /** 取数策略:list = 全量翻清单后过滤,subset = 跳过翻页直接对 -a 指定的 ID 拉数据 */
4332
+ source: scannedFromList ? "list" : "subset",
4040
4333
  returned: rows.length,
4041
4334
  totals: {
4042
4335
  spend: +totals.spend.toFixed(2),
@@ -8004,21 +8297,39 @@ async function runBalanceScan(opts) {
8004
8297
  const targetDays = opts.targetDays ?? 30;
8005
8298
  const pageSize = Math.max(1, Math.min(500, opts.pageSize ?? 200));
8006
8299
  const maxPages = Math.max(1, Math.min(200, opts.maxPages ?? 20));
8007
- const { items: allItems, total } = await fetchAllAccountPages(
8008
- media,
8009
- pageSize,
8010
- maxPages,
8011
- config,
8012
- opts.verbose
8013
- );
8014
- process.stderr.write(
8015
- `\r\u23F3 \u8D26\u6237\u6E05\u5355\u5DF2\u62C9\u53D6\uFF1A\u5171 ${allItems.length} \u6761${total !== void 0 ? `\uFF08\u63A5\u53E3 total\u2248${total}\uFF09` : ""}
8300
+ const filterIds = (opts.accounts ?? "").split(",").map((s) => s.trim()).filter(Boolean);
8301
+ const isSubset = filterIds.length > 0;
8302
+ let allItems;
8303
+ let total;
8304
+ if (isSubset) {
8305
+ allItems = filterIds.map((id) => ({
8306
+ ma: {
8307
+ entityId: "",
8308
+ mediaCustomerId: id,
8309
+ mediaCustomerName: null,
8310
+ mediaAccountType: media,
8311
+ invalidOAuthToken: false
8312
+ },
8313
+ accepted: true,
8314
+ mag: null
8315
+ }));
8316
+ process.stderr.write(
8317
+ `\u23F3 [balance-scan] \u5B50\u96C6\u6A21\u5F0F\uFF1A\u8DF3\u8FC7\u6E05\u5355\u7FFB\u9875\uFF0C\u76F4\u63A5\u5BF9 -a \u6307\u5B9A\u7684 ${filterIds.length} \u4E2A\u8D26\u6237\u62C9\u4F59\u989D\u4E0E\u6D88\u8017\u3002
8016
8318
  `
8017
- );
8319
+ );
8320
+ } else {
8321
+ const res = await fetchAllAccountPages(media, pageSize, maxPages, config, opts.verbose);
8322
+ allItems = res.items;
8323
+ total = res.total;
8324
+ process.stderr.write(
8325
+ `\r\u23F3 \u8D26\u6237\u6E05\u5355\u5DF2\u62C9\u53D6\uFF1A\u5171 ${allItems.length} \u6761${total !== void 0 ? `\uFF08\u63A5\u53E3 total\u2248${total}\uFF09` : ""}
8326
+ `
8327
+ );
8328
+ }
8018
8329
  const validIds = [];
8019
8330
  for (const item of allItems) {
8020
8331
  const id = item.ma?.mediaCustomerId;
8021
- if (id && !item.ma?.invalidOAuthToken) validIds.push(String(id));
8332
+ if (id && (isSubset || !item.ma?.invalidOAuthToken)) validIds.push(String(id));
8022
8333
  }
8023
8334
  let balanceMap = /* @__PURE__ */ new Map();
8024
8335
  let overviewMap = /* @__PURE__ */ new Map();
@@ -8096,19 +8407,19 @@ async function runBalanceScan(opts) {
8096
8407
  }
8097
8408
  const lowDays = remainingDays !== null && remainingDays <= thresholdDays;
8098
8409
  const lowBalance = minBalance !== null && balance !== null && balance <= minBalance;
8099
- if (!lowDays && !lowBalance) continue;
8100
- const hitReason = lowDays && lowBalance ? "both" : lowDays ? "low-days" : "low-balance";
8410
+ if (!lowDays && !lowBalance && !isSubset) continue;
8411
+ const hitReason = lowDays && lowBalance ? "both" : lowDays ? "low-days" : lowBalance ? "low-balance" : "none";
8101
8412
  evaluated.push({
8102
8413
  mediaCustomerId: id,
8103
- name: ma.mediaCustomerName ?? null,
8414
+ name: ma.mediaCustomerName ?? bal?.name ?? ov?.mediaCustomerName ?? null,
8104
8415
  advertiserName: item.mag?.advertiserName ?? null,
8105
- currencyCode: bal?.currencyCode ?? ma.currencyCode ?? null,
8416
+ currencyCode: bal?.currencyCode ?? ma.currencyCode ?? ov?.currencyCode ?? null,
8106
8417
  balance,
8107
8418
  dailySpend: dailySpend > 0 ? +dailySpend.toFixed(2) : null,
8108
8419
  remainingDays,
8109
8420
  recommendedTopup,
8110
8421
  invalidOAuthToken: !!ma.invalidOAuthToken,
8111
- status: bal?.status ?? bal?.accountStatus ?? null,
8422
+ status: bal?.status ?? bal?.accountStatus ?? ov?.status ?? null,
8112
8423
  hitReason
8113
8424
  });
8114
8425
  }
@@ -8119,6 +8430,8 @@ async function runBalanceScan(opts) {
8119
8430
  });
8120
8431
  const meta = {
8121
8432
  media,
8433
+ /** 取数策略:list = 全量翻清单后阈值过滤,subset = -a 指定 ID 跳过翻页全部展示 */
8434
+ source: isSubset ? "subset" : "list",
8122
8435
  scannedAccounts: allItems.length,
8123
8436
  validAccounts: validIds.length,
8124
8437
  skippedInvalidOAuth: allItems.length - validIds.length,
@@ -8144,11 +8457,20 @@ async function runBalanceScan(opts) {
8144
8457
  })) {
8145
8458
  return;
8146
8459
  }
8147
- console.log(
8148
- `
8460
+ if (isSubset) {
8461
+ console.log(
8462
+ `
8463
+ ${media} \u8D26\u6237\u4F59\u989D\u753B\u50CF\uFF08\u5B50\u96C6\u6A21\u5F0F\uFF0C-a \u6307\u5B9A ${filterIds.length} \u4E2A\u8D26\u6237\uFF0C\u5168\u90E8\u5C55\u793A\uFF09
8464
+ \u9608\u503C\u53C2\u8003\uFF1A\u7EED\u822A \u2264 ${thresholdDays} \u5929` + (minBalance !== null ? ` \u6216 \u4F59\u989D \u2264 ${minBalance}` : "") + `
8465
+ `
8466
+ );
8467
+ } else {
8468
+ console.log(
8469
+ `
8149
8470
  ${media} \u8D26\u6237\u4F59\u989D\u626B\u63CF\u5B8C\u6210\uFF1A\u5171\u626B\u63CF ${meta.scannedAccounts} \u4E2A\u8D26\u6237\uFF08\u6709\u6548 ${meta.validAccounts}\uFF09\uFF0C\u547D\u4E2D ${meta.hitCount} \u4E2A\uFF08\u9608\u503C\uFF1A\u7EED\u822A \u2264 ${thresholdDays} \u5929` + (minBalance !== null ? ` \u6216 \u4F59\u989D \u2264 ${minBalance}` : "") + `\uFF09
8150
8471
  `
8151
- );
8472
+ );
8473
+ }
8152
8474
  if (evaluated.length === 0) {
8153
8475
  console.log(" \u2705 \u65E0\u547D\u4E2D\u8D26\u6237\u3002\n");
8154
8476
  return;
@@ -8164,6 +8486,7 @@ ${media} \u8D26\u6237\u4F59\u989D\u626B\u63CF\u5B8C\u6210\uFF1A\u5171\u626B\u63C
8164
8486
  { key: "reason", header: "\u547D\u4E2D\u539F\u56E0" }
8165
8487
  ];
8166
8488
  const reasonText = {
8489
+ none: "\u2014\uFF08\u5B50\u96C6\u5C55\u793A\uFF0C\u672A\u89E6\u9608\u503C\uFF09",
8167
8490
  "low-days": `\u7EED\u822A \u2264 ${thresholdDays} \u5929`,
8168
8491
  "low-balance": `\u4F59\u989D \u2264 ${minBalance ?? 0}`,
8169
8492
  both: `\u7EED\u822A + \u4F59\u989D\u53CC\u4F4E`
@@ -8197,6 +8520,7 @@ init_accounts_digest();
8197
8520
  init_auth();
8198
8521
  init_cli_json_snapshot();
8199
8522
  init_cli_table();
8523
+ init_balance();
8200
8524
  var VALID_MEDIA_TYPES3 = ["Google", "TikTok", "Yandex", "MetaAd", "BingV2", "Kwai"];
8201
8525
  function defaultDateRange2() {
8202
8526
  const end = /* @__PURE__ */ new Date();
@@ -8222,7 +8546,6 @@ async function runStats(opts) {
8222
8546
  ...opts.startDate ? { startDate: opts.startDate } : {},
8223
8547
  ...opts.endDate ? { endDate: opts.endDate } : {}
8224
8548
  };
8225
- const params = new URLSearchParams({ startDate, endDate, period: "true" });
8226
8549
  if (!opts.accounts) {
8227
8550
  console.error(
8228
8551
  `
@@ -8234,11 +8557,33 @@ async function runStats(opts) {
8234
8557
  process.exit(1);
8235
8558
  }
8236
8559
  const ids = opts.accounts.split(",").map((id) => id.trim()).filter(Boolean);
8237
- params.set("mediaCustomerIds", ids.join(","));
8238
- const url = `${config.apiBaseUrl}/report/media-account/${opts.media}/accountsoverview?${params.toString()}`;
8239
- let raw;
8560
+ const isGoogle = opts.media === "Google";
8561
+ const params = isGoogle ? new URLSearchParams({ startDate, endDate, mediaCustomerIds: ids.join("|") }) : (() => {
8562
+ const p = new URLSearchParams({ startDate, endDate, period: "true" });
8563
+ p.set("mediaCustomerIds", ids.join(","));
8564
+ return p;
8565
+ })();
8566
+ const url = isGoogle ? `${config.apiBaseUrl}/report/media-account/google/account-spend-overview?${params.toString()}` : `${config.apiBaseUrl}/report/media-account/${opts.media}/accountsoverview?${params.toString()}`;
8567
+ let items;
8240
8568
  try {
8241
- raw = await apiFetch2(url, config, {}, opts.verbose);
8569
+ if (isGoogle) {
8570
+ const raw = await apiFetch2(url, config, {}, opts.verbose);
8571
+ items = parseGoogleAccountSpendOverviewRows(raw).map((row) => ({
8572
+ mediaAccountId: row.mediaAccountId,
8573
+ mediaCustomerName: row.mediaCustomerName,
8574
+ spend: row.spend,
8575
+ impressions: row.impressions,
8576
+ clicks: row.clicks,
8577
+ conversions: row.conversions,
8578
+ costPerClick: row.costPerClick,
8579
+ currencyCode: row.currencyCode,
8580
+ remainingAccountBudget: row.remainingAccountBudget,
8581
+ status: row.status
8582
+ }));
8583
+ } else {
8584
+ const raw = await apiFetch2(url, config, {}, opts.verbose);
8585
+ items = Array.isArray(raw) ? raw : raw.items ?? [];
8586
+ }
8242
8587
  } catch (err) {
8243
8588
  const message = err instanceof Error ? err.message : String(err);
8244
8589
  if (await emitCliJsonOrSnapshot(opts, {
@@ -8255,7 +8600,6 @@ async function runStats(opts) {
8255
8600
  `);
8256
8601
  process.exit(1);
8257
8602
  }
8258
- const items = Array.isArray(raw) ? raw : raw.items ?? [];
8259
8603
  const n = items.length;
8260
8604
  const statsPayload = wrapListJson({ page: 1, pageSize: Math.max(n, 1), total: n, items });
8261
8605
  if (await emitCliJsonOrSnapshot(opts, {
@@ -16026,6 +16370,7 @@ TikTok \u6CE8\u518C\u5730\u5217\u8868\uFF08\u7B2C 1 \u9875\uFF0C\u672C\u9875 ${r
16026
16370
  init_defaults();
16027
16371
  init_dist();
16028
16372
  import * as readline2 from "readline";
16373
+ import * as os5 from "os";
16029
16374
  function deriveWebBaseUrl(tsoApiBase) {
16030
16375
  try {
16031
16376
  const u = new URL(tsoApiBase);
@@ -16075,6 +16420,138 @@ function printPostLoginReminderBanner() {
16075
16420
  ];
16076
16421
  console.log(lines.join("\n"));
16077
16422
  }
16423
+ function parseAllowedServices(raw) {
16424
+ const allowed = ["CSO", "TSO", "CUT"];
16425
+ if (!raw) return ["TSO", "CUT"];
16426
+ const parts = raw.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
16427
+ const result = [];
16428
+ for (const p of parts) {
16429
+ if (!allowed.includes(p)) {
16430
+ throw new Error(`\u672A\u77E5\u670D\u52A1\u540D\u300C${p}\u300D\uFF0C\u53EF\u9009\uFF1A${allowed.join(" / ")}`);
16431
+ }
16432
+ if (!result.includes(p)) result.push(p);
16433
+ }
16434
+ if (result.length === 0) {
16435
+ throw new Error("--services \u81F3\u5C11\u9700\u8981\u6307\u5B9A\u4E00\u4E2A\u670D\u52A1");
16436
+ }
16437
+ return result;
16438
+ }
16439
+ function defaultApiKeyName() {
16440
+ const host = (() => {
16441
+ try {
16442
+ return os5.hostname() || "unknown";
16443
+ } catch {
16444
+ return "unknown";
16445
+ }
16446
+ })();
16447
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
16448
+ return `CLI - ${host} - ${today}`;
16449
+ }
16450
+ async function runPhoneLogin(opts) {
16451
+ const rawPhone = opts.phone?.trim() ?? "";
16452
+ if (!isValidChinaPhone(rawPhone)) {
16453
+ console.error(
16454
+ "\n\u274C \u624B\u673A\u53F7\u683C\u5F0F\u9519\u8BEF\uFF1A\u4EC5\u652F\u6301\u4E2D\u56FD\u5927\u9646\u624B\u673A\u53F7\uFF0C\u53EF\u5E26\u6216\u4E0D\u5E26 +86\uFF08\u5982 13800138000 / +8613800138000\uFF09\u3002\n"
16455
+ );
16456
+ process.exit(1);
16457
+ }
16458
+ const phone = normalizeChinaPhone(rawPhone);
16459
+ let allowedServices;
16460
+ try {
16461
+ allowedServices = parseAllowedServices(opts.services);
16462
+ } catch (e) {
16463
+ console.error(`
16464
+ \u274C ${e instanceof Error ? e.message : String(e)}
16465
+ `);
16466
+ process.exit(1);
16467
+ return;
16468
+ }
16469
+ if (opts.validDays !== void 0 && opts.expiresAt !== void 0) {
16470
+ console.warn("\u26A0\uFE0F --valid-days \u4E0E --expires-at \u540C\u65F6\u4F20\u5165\uFF0C\u5C06\u4EE5 --expires-at \u4E3A\u51C6\u3002");
16471
+ }
16472
+ const tsoApiBase = process.env.SILUZAN_TSO_API_BASE ?? DEFAULT_API_BASE;
16473
+ const ssoBaseUrl = deriveSsoBaseUrl(tsoApiBase);
16474
+ const csoBaseUrl = deriveCsoApiBaseUrl(tsoApiBase);
16475
+ console.log("\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
16476
+ console.log(" Siluzan \u767B\u5F55\uFF08\u624B\u673A\u53F7 + \u77ED\u4FE1\u9A8C\u8BC1\u7801\uFF09");
16477
+ console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
16478
+ console.log(` \u624B\u673A\u53F7 : ${phone}`);
16479
+ console.log(` SSO : ${ssoBaseUrl}`);
16480
+ console.log(` CSO API : ${csoBaseUrl}`);
16481
+ console.log(` \u670D\u52A1\u8303\u56F4 : ${allowedServices.join(", ")}`);
16482
+ if (opts.expiresAt) {
16483
+ console.log(` \u8FC7\u671F\u65F6\u95F4 : ${opts.expiresAt}`);
16484
+ } else {
16485
+ console.log(` \u6709\u6548\u671F : ${opts.validDays ?? 90} \u5929`);
16486
+ }
16487
+ const apiKeyName = opts.name ?? defaultApiKeyName();
16488
+ console.log(` API Key \u540D\u79F0 : ${apiKeyName}
16489
+ `);
16490
+ console.log("\u2192 \u6B63\u5728\u5411\u624B\u673A\u53D1\u9001\u9A8C\u8BC1\u7801...");
16491
+ try {
16492
+ const created = await runPhoneLoginAndIssueApiKey({
16493
+ ssoBaseUrl,
16494
+ csoBaseUrl,
16495
+ phone,
16496
+ apiKeyName,
16497
+ validDays: opts.expiresAt ? void 0 : opts.validDays ?? 90,
16498
+ expiresAt: opts.expiresAt,
16499
+ allowedServices,
16500
+ verbose: opts.verbose,
16501
+ getCode: async () => {
16502
+ if (opts.code !== void 0) {
16503
+ const trimmed = opts.code.trim();
16504
+ if (!trimmed) {
16505
+ console.error("\u274C --code \u4E0D\u80FD\u4E3A\u7A7A\u3002");
16506
+ return null;
16507
+ }
16508
+ console.log(`\u2713 \u9A8C\u8BC1\u7801\u5DF2\u53D1\u9001\uFF0C\u4F7F\u7528\u547D\u4EE4\u884C\u4F20\u5165\u7684 code\uFF08${trimmed}\uFF09\u3002`);
16509
+ return trimmed;
16510
+ }
16511
+ console.log("\u2713 \u9A8C\u8BC1\u7801\u5DF2\u53D1\u9001\uFF0810 \u5206\u949F\u5185\u6709\u6548\uFF09\u3002");
16512
+ const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
16513
+ const input = await new Promise(
16514
+ (res) => rl.question("\u8BF7\u8F93\u5165\u6536\u5230\u7684 6 \u4F4D\u9A8C\u8BC1\u7801\uFF1A", (a) => {
16515
+ rl.close();
16516
+ res(a.trim());
16517
+ })
16518
+ );
16519
+ return input || null;
16520
+ }
16521
+ });
16522
+ writeSharedConfig({ apiKey: created.rawKey });
16523
+ console.log("\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
16524
+ console.log("\u2705 \u767B\u5F55\u6210\u529F\uFF0CAPI Key \u5DF2\u521B\u5EFA\u5E76\u4FDD\u5B58");
16525
+ console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
16526
+ console.log(` Key ID : ${created.id}`);
16527
+ console.log(` Key Name : ${created.name}`);
16528
+ console.log(` Key (\u663E\u793A) : ${maskSecret(created.rawKey)}`);
16529
+ console.log(` Key (\u8131\u654F) : ${created.keyMasked}`);
16530
+ console.log(` \u751F\u6548\u65F6\u95F4 : ${created.validFrom}`);
16531
+ console.log(` \u8FC7\u671F\u65F6\u95F4 : ${created.expiresAt}`);
16532
+ console.log(` \u5269\u4F59\u5929\u6570 : ${created.remainingDays} \u5929`);
16533
+ console.log(` \u72B6\u6001 : ${created.status}`);
16534
+ console.log(` \u914D\u7F6E\u6587\u4EF6 : ${CONFIG_FILE}`);
16535
+ printPostLoginReminderBanner();
16536
+ console.log("\u73B0\u5728\u53EF\u4EE5\u8FD0\u884C\uFF1A");
16537
+ console.log(" siluzan-tso list-accounts \u67E5\u770B\u5E7F\u544A\u8D26\u6237\u5217\u8868");
16538
+ console.log(" siluzan-tso balance -m Google \u67E5\u770B\u8D26\u6237\u4F59\u989D\n");
16539
+ } catch (e) {
16540
+ const msg = e instanceof Error ? e.message : String(e);
16541
+ console.error(`
16542
+ \u274C \u767B\u5F55\u5931\u8D25\uFF1A${msg}`);
16543
+ if (/手机未注册|未注册/.test(msg)) {
16544
+ console.error(
16545
+ `
16546
+ \u8BE5\u624B\u673A\u53F7\u5C1A\u672A\u6CE8\u518C\u4E1D\u8DEF\u8D5E\u8D26\u53F7\u3002\u8BF7\u5148\u5728\u7F51\u9875\u7AEF\u6CE8\u518C\uFF1A
16547
+ ${WEB_BASE_URL}
16548
+ \u6CE8\u518C\u6210\u529F\u540E\u518D\u56DE\u5230 CLI \u91CD\u8BD5 siluzan-tso login --phone ${phone}
16549
+ `
16550
+ );
16551
+ }
16552
+ process.exit(1);
16553
+ }
16554
+ }
16078
16555
  async function runLogin(opts = {}) {
16079
16556
  if (opts.apiKey !== void 0) {
16080
16557
  const key = opts.apiKey.trim();
@@ -16090,6 +16567,10 @@ async function runLogin(opts = {}) {
16090
16567
  console.log("\u73B0\u5728\u53EF\u4EE5\u8FD0\u884C siluzan-tso -h \u547D\u4EE4\u67E5\u770B\u5E2E\u52A9\u4E86\n");
16091
16568
  return;
16092
16569
  }
16570
+ if (opts.phone !== void 0) {
16571
+ await runPhoneLogin(opts);
16572
+ return;
16573
+ }
16093
16574
  const shared = readSharedConfig();
16094
16575
  const currentKey = shared.apiKey ?? "";
16095
16576
  if (currentKey) {
@@ -16188,9 +16669,26 @@ program.hook("preAction", async () => {
16188
16669
  });
16189
16670
  }
16190
16671
  });
16191
- program.command("login").description("\u4FDD\u5B58\u8BA4\u8BC1\u51ED\u636E\u5230 ~/.siluzan/config.json\uFF08Token \u6216 API Key \u4E8C\u9009\u4E00\uFF09").option("--api-key <key>", "\u76F4\u63A5\u4FDD\u5B58 API Key\uFF08\u5728\u4E1D\u8DEF\u8D5E\u300C\u8BBE\u7F6E \u2192 API Key \u7BA1\u7406\u300D\u521B\u5EFA\uFF09\uFF0C\u63A8\u8350\u65B9\u5F0F").option("--manual", "\u4EA4\u4E92\u5F0F\u7C98\u8D34 JWT Token\uFF08\u65E0 API Key \u65F6\u4F7F\u7528\uFF09").action(async (opts) => {
16192
- await runLogin({ apiKey: opts.apiKey });
16193
- });
16672
+ program.command("login").description("\u4FDD\u5B58\u8BA4\u8BC1\u51ED\u636E\u5230 ~/.siluzan/config.json\uFF08API Key / \u624B\u673A\u53F7\u9A8C\u8BC1\u7801 / \u4EA4\u4E92\u5F0F\u4E09\u9009\u4E00\uFF09").option("--api-key <key>", "\u76F4\u63A5\u4FDD\u5B58 API Key\uFF08\u5728\u4E1D\u8DEF\u8D5E\u300C\u8BBE\u7F6E \u2192 API Key \u7BA1\u7406\u300D\u521B\u5EFA\uFF09\uFF0C\u6700\u9AD8\u4F18\u5148\u7EA7").option(
16673
+ "--phone <phone>",
16674
+ "11 \u4F4D\u624B\u673A\u53F7\uFF1B\u89E6\u53D1\u77ED\u4FE1\u9A8C\u8BC1\u7801\u767B\u5F55\u5E76\u81EA\u52A8\u7B7E\u53D1 API Key\uFF08\u9700\u624B\u673A\u53F7\u5DF2\u5728\u4E1D\u8DEF\u8D5E\u6CE8\u518C\uFF09"
16675
+ ).option("--code <code>", "\u77ED\u4FE1\u9A8C\u8BC1\u7801\uFF1B\u4E0E --phone \u914D\u5408\u4F7F\u7528\uFF0C\u8DF3\u8FC7\u4EA4\u4E92\u5F0F\u8F93\u5165").option("--name <name>", "\u81EA\u52A8\u521B\u5EFA\u7684 API Key \u540D\u79F0\uFF0C\u9ED8\u8BA4 `CLI - <hostname> - <yyyy-MM-dd>`").option("--valid-days <days>", "API Key \u6709\u6548\u671F\uFF08\u5929\uFF09\uFF0C\u9ED8\u8BA4 90", (v) => parseInt(v, 10)).option("--expires-at <iso8601>", "API Key \u7EDD\u5BF9\u8FC7\u671F\u65F6\u95F4\uFF08ISO 8601\uFF09\uFF0C\u4E0E --valid-days \u4E8C\u9009\u4E00").option(
16676
+ "--services <list>",
16677
+ "API Key \u53EF\u8BBF\u95EE\u7684\u670D\u52A1\u5217\u8868\uFF0C\u9017\u53F7\u5206\u9694\uFF1B\u53EF\u9009 CSO/TSO/CUT\uFF0C\u9ED8\u8BA4 `TSO,CUT`"
16678
+ ).option("--manual", "\u4EA4\u4E92\u5F0F\u7C98\u8D34 JWT Token\uFF08\u65E0 API Key \u65F6\u4F7F\u7528\uFF0C\u5DF2\u5E9F\u5F03\uFF09").option("--verbose", "\u8F93\u51FA\u6BCF\u6B21 HTTP \u8BF7\u6C42 URL\uFF08\u8C03\u8BD5\u7528\uFF09").action(
16679
+ async (opts) => {
16680
+ await runLogin({
16681
+ apiKey: opts.apiKey,
16682
+ phone: opts.phone,
16683
+ code: opts.code,
16684
+ name: opts.name,
16685
+ validDays: opts.validDays,
16686
+ expiresAt: opts.expiresAt,
16687
+ services: opts.services,
16688
+ verbose: opts.verbose
16689
+ });
16690
+ }
16691
+ );
16194
16692
  var configCmd = program.command("config").description("\u67E5\u770B\u6216\u8BBE\u7F6E\u8BA4\u8BC1\u914D\u7F6E\uFF08\u4E0E siluzan-cso \u5171\u7528 ~/.siluzan/config.json\uFF09");
16195
16693
  configCmd.command("show").description("\u5C55\u793A\u5F53\u524D\u5DF2\u4FDD\u5B58\u7684\u914D\u7F6E\uFF08Token \u8131\u654F\u663E\u793A\uFF09").action(() => cmdConfigShow());
16196
16694
  configCmd.command("set").description("\u4FDD\u5B58\u914D\u7F6E\u5230 ~/.siluzan/config.json").option("--api-key <key>", "API Key\uFF08\u5728\u4E1D\u8DEF\u8D5E\u300C\u8BBE\u7F6E \u2192 API Key \u7BA1\u7406\u300D\u521B\u5EFA\uFF0C\u63A8\u8350\uFF09").option("-t, --token <token>", "JWT \u7528\u6237 Auth Token\uFF08\u4E0E --api-key \u4E8C\u9009\u4E00\uFF09").action((opts) => {
@@ -16363,10 +16861,13 @@ program.command("balance").description("\u67E5\u8BE2\u5E7F\u544A\u8D26\u6237\u5B
16363
16861
  }
16364
16862
  );
16365
16863
  program.command("balance-scan").description(
16366
- "\u4E00\u952E\u626B\u63CF\u67D0\u5A92\u4F53\u4E0B\u6240\u6709\u8D26\u6237\u7684\u4F59\u989D\uFF0C\u7B5B\u51FA\u7EED\u822A\u5929\u6570\u4E0D\u8DB3\u7684\u8D26\u6237\u3002\u66FF\u4EE3\u5FAA\u73AF\u8C03\u7528 balance \u7684\u4F20\u7EDF\u505A\u6CD5\uFF0C\u5178\u578B\u573A\u666F\uFF1A\u300C\u5E2E\u6211\u627E\u51FA 117 \u4E2A Bing \u8D26\u6237\u91CC\u4F59\u989D\u4E0D\u8DB3 7 \u5929\u7684\u300D\u3002"
16864
+ "\u4E00\u952E\u626B\u63CF\u67D0\u5A92\u4F53\u4E0B\u6240\u6709\u8D26\u6237\u7684\u4F59\u989D\uFF0C\u7B5B\u51FA\u7EED\u822A\u5929\u6570\u4E0D\u8DB3\u7684\u8D26\u6237\u3002\u66FF\u4EE3\u5FAA\u73AF\u8C03\u7528 balance \u7684\u4F20\u7EDF\u505A\u6CD5\uFF0C\u5178\u578B\u573A\u666F\uFF1A\u300C\u5E2E\u6211\u627E\u51FA 117 \u4E2A Bing \u8D26\u6237\u91CC\u4F59\u989D\u4E0D\u8DB3 7 \u5929\u7684\u300D\u3002\u5982\u5DF2\u77E5\u8D26\u6237 ID \u5B50\u96C6\uFF08\u5C11\u91CF\u8D26\u6237\u753B\u50CF\uFF09\uFF0C\u52A0 -a <id1,id2> \u8DF3\u8FC7\u6E05\u5355\u7FFB\u9875\u76F4\u63A5\u67E5\u3002"
16367
16865
  ).requiredOption(
16368
16866
  "-m, --media <type>",
16369
16867
  "\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Yandex | BingV2 | Kwai\uFF08MetaAd \u63A5\u53E3\u672A\u5F00\u653E\u4F59\u989D\u67E5\u8BE2\uFF09"
16868
+ ).option(
16869
+ "-a, --accounts <ids>",
16870
+ "\u6307\u5B9A\u8D26\u6237 mediaCustomerId\uFF08\u9017\u53F7\u5206\u9694\uFF09\u3002\u5B50\u96C6\u6A21\u5F0F\uFF1A\u8DF3\u8FC7\u6E05\u5355\u7FFB\u9875\u76F4\u63A5\u67E5\u8FD9\u4E9B ID\uFF0C\u5168\u90E8\u5C55\u793A\uFF08\u4E0D\u518D\u6309\u9608\u503C\u4E22\u5F03\uFF09\uFF0C\u9002\u5408\u300C\u770B\u51E0\u4E2A\u53F7\u4F59\u989D/\u7EED\u822A\u300D\u7684\u8F7B\u91CF\u573A\u666F\uFF1B\u7559\u7A7A\u5219\u5168\u91CF\u626B\u63CF+\u9608\u503C\u8FC7\u6EE4\u3002"
16370
16871
  ).option(
16371
16872
  "--threshold-days <n>",
16372
16873
  "\u5269\u4F59\u7EED\u822A\u5929\u6570\u9608\u503C\uFF08\u9ED8\u8BA4 7\uFF0C\u6309\u8FD1 7 \u65E5\u65E5\u5747\u6D88\u8017\u4F30\u7B97\uFF09",
@@ -16392,6 +16893,7 @@ program.command("balance-scan").description(
16392
16893
  await runBalanceScan({
16393
16894
  token: opts.token,
16394
16895
  media: opts.media,
16896
+ accounts: opts.accounts,
16395
16897
  thresholdDays: opts.thresholdDays,
16396
16898
  minBalance: opts.minBalance,
16397
16899
  minDailySpend: opts.minDailySpend,
@@ -16410,7 +16912,10 @@ program.command("accounts-digest").description(
16410
16912
  ).requiredOption(
16411
16913
  "-m, --media <type>",
16412
16914
  "\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Yandex | BingV2 | Kwai\uFF08MetaAd \u65E0\u6D88\u8017\u6C47\u603B\u63A5\u53E3\uFF09"
16413
- ).option("-a, --accounts <ids>", "\u6307\u5B9A\u8D26\u6237 mediaCustomerId\uFF0C\u9017\u53F7\u5206\u9694\uFF1B\u7559\u7A7A\u5219\u626B\u63CF\u8BE5\u5A92\u4F53\u4E0B\u5168\u90E8\u8D26\u6237").option("--start <date>", "\u7EDF\u8BA1\u5F00\u59CB\u65E5\u671F YYYY-MM-DD\uFF08SKILL \u8981\u6C42 AI \u5148\u4E0E\u7528\u6237\u786E\u8BA4\uFF09").option("--end <date>", "\u7EDF\u8BA1\u7ED3\u675F\u65E5\u671F YYYY-MM-DD\uFF08SKILL \u8981\u6C42 AI \u5148\u4E0E\u7528\u6237\u786E\u8BA4\uFF09").option("--min-spend <n>", "\u8FC7\u6EE4\uFF1A\u533A\u95F4\u5185\u6D88\u8017 \u2264 \u6B64\u503C\u7684\u8D26\u6237\u4E0D\u8FD4\u56DE\uFF08\u9ED8\u8BA4 0\uFF09", (v) => parseFloat(v)).option("--page-size <n>", "\u8D26\u6237\u6E05\u5355\u5206\u9875\u5927\u5C0F\uFF08\u9ED8\u8BA4 200\uFF0C\u4E0A\u9650 500\uFF09", (v) => parseInt(v, 10)).option("--max-pages <n>", "\u6700\u591A\u626B\u63CF\u9875\u6570\uFF08\u9ED8\u8BA4 20\uFF0C\u4E0A\u9650 200\uFF09", (v) => parseInt(v, 10)).option("-t, --token <token>", "Token\uFF08\u53EF\u9009\uFF1B\u4F18\u5148\u4E8E ~/.siluzan/config.json\uFF09").option("--json", "\u8F93\u51FA\u7ED3\u6784\u5316 JSON\uFF0C\u542B data.items \u4E0E meta\uFF08\u533A\u95F4/\u6C47\u603B/\u5E01\u79CD\u5907\u6CE8\uFF09", false).option(
16915
+ ).option(
16916
+ "-a, --accounts <ids>",
16917
+ "\u6307\u5B9A\u8D26\u6237 mediaCustomerId\uFF08\u9017\u53F7\u5206\u9694\uFF09\u3002\u5B50\u96C6\u6A21\u5F0F\uFF1A\u8DF3\u8FC7\u6E05\u5355\u7FFB\u9875\u76F4\u63A5\u67E5\u8FD9\u4E9B ID\uFF08\u516C\u53F8\u540D advertiserName \u4F1A\u7F3A\u5931\uFF09\uFF0C\u9002\u5408\u300C\u770B\u51E0\u4E2A\u53F7\u753B\u50CF\u300D\u7684\u8F7B\u91CF\u573A\u666F\uFF1B\u7559\u7A7A\u5219\u5168\u91CF\u7FFB\u6E05\u5355\u3002"
16918
+ ).option("--start <date>", "\u7EDF\u8BA1\u5F00\u59CB\u65E5\u671F YYYY-MM-DD\uFF08SKILL \u8981\u6C42 AI \u5148\u4E0E\u7528\u6237\u786E\u8BA4\uFF09").option("--end <date>", "\u7EDF\u8BA1\u7ED3\u675F\u65E5\u671F YYYY-MM-DD\uFF08SKILL \u8981\u6C42 AI \u5148\u4E0E\u7528\u6237\u786E\u8BA4\uFF09").option("--min-spend <n>", "\u8FC7\u6EE4\uFF1A\u533A\u95F4\u5185\u6D88\u8017 \u2264 \u6B64\u503C\u7684\u8D26\u6237\u4E0D\u8FD4\u56DE\uFF08\u9ED8\u8BA4 0\uFF09", (v) => parseFloat(v)).option("--page-size <n>", "\u8D26\u6237\u6E05\u5355\u5206\u9875\u5927\u5C0F\uFF08\u9ED8\u8BA4 200\uFF0C\u4E0A\u9650 500\uFF09", (v) => parseInt(v, 10)).option("--max-pages <n>", "\u6700\u591A\u626B\u63CF\u9875\u6570\uFF08\u9ED8\u8BA4 20\uFF0C\u4E0A\u9650 200\uFF09", (v) => parseInt(v, 10)).option("-t, --token <token>", "Token\uFF08\u53EF\u9009\uFF1B\u4F18\u5148\u4E8E ~/.siluzan/config.json\uFF09").option("--json", "\u8F93\u51FA\u7ED3\u6784\u5316 JSON\uFF0C\u542B data.items \u4E0E meta\uFF08\u533A\u95F4/\u6C47\u603B/\u5E01\u79CD\u5907\u6CE8\uFF09", false).option(
16414
16919
  "--json-out <path>",
16415
16920
  "\u843D\u76D8\uFF08\u76EE\u5F55\u6216 *.json \u6587\u4EF6\u8DEF\u5F84\uFF09\u5E76\u66F4\u65B0 cli-manifest[-<\u67E5\u8BE2id>].json\uFF08\u4E0E --json \u4E92\u65A5\uFF09\uFF1B\u76EE\u5F55\u6A21\u5F0F\u6587\u4EF6\u540D\u4E3A `<section>[-<\u67E5\u8BE2id>].json`\uFF1Bstdout \u4E00\u884C\u6458\u8981 JSON\uFF0C\u542B outlineFile\uFF08TS \u5F0F\u7C7B\u578B\u5728 `*.outline.txt`\uFF09",
16416
16921
  void 0
@@ -94,7 +94,7 @@ Windows 注意:部分 Agent 客户端通过 PowerShell / cmd 代执行命令
94
94
  ### 硬规范
95
95
 
96
96
  - **账户状态 ≠ 广告系列状态**:`stats`/`balance`/`list-accounts` 的 `status` 只表示账户是否可用,系列状态**必须**来自 `ad campaigns`。
97
- - **数据时效性(实时 vs 每日同步)**:涉及「今天/当天/今日消耗」「实时消耗排行」前,**必须**先看 `references/account-analytics.md` 顶部「数据时效性」表选对接口(`accountsoverview` 系列接口同步昨天数据,不能查今天)。
97
+ - **数据时效性(实时 vs 每日同步)**:涉及「今天/当天/今日消耗」「实时消耗排行」前,**必须**先看 `references/account-analytics.md` 顶部「数据时效性」表选对接口,TikTok / Yandex / BingV2 / Kwai 仍是 `accountsoverview` 同步昨天数据,**不能查今天**。
98
98
  - **不确定时读文档**:先读对应 references 或用 `-h` 查看帮助,不要猜参数。
99
99
  - **先查账户再操作**:`list-accounts -m [mediaType] -k [mediaCustomerId]`。
100
100
  - **使用 `--json-out` 处理数据**:处理顺序:先读 `outlineFile`(schema 描述,扩展名 **`.outline.txt`** 不是 `.json`,**禁止 `require()`**,用 `fs.readFileSync(outlineFile,'utf8')` 取最后一行 TS 式类型字面量即可了解全部字段路径)→ 再让脚本读 `writtenFiles[0]`(真实数据 JSON)做聚合。**outline 通常几百字节、JSON 常见几 MB,先读 outline 能节省 2~3 个数量级的上下文**——尤其多账户多维度场景,直接 `Read` 全量 JSON 几次就把对话窗口塞满了。**禁止**把 outline 当数据、跳过 outline 猜字段、把 outline 内容贴给用户当结论。
@@ -135,8 +135,8 @@ Windows 注意:部分 Agent 客户端通过 PowerShell / cmd 代执行命令
135
135
 
136
136
  | 任务 | 推荐命令 | 禁止做法 |
137
137
  |------|---------|---------|
138
- | 多账户余额 / 预算不足预警 | `balance-scan -m <媒体> --threshold-days 7` | 逐账户循环 `balance --accounts ...` |
139
- | 多账户投放画像 | `accounts-digest -m <媒体> [-a id1,id2] --start --end --json-out <dir>` | 逐账户 `stats` |
138
+ | 多账户余额 / 预算不足预警 | `balance-scan -m <媒体> --threshold-days 7`(已知 ID 子集加 `-a id1,id2,...` 跳过翻页) | 逐账户循环 `balance --accounts ...` |
139
+ | 多账户投放画像 | `accounts-digest -m <媒体> [-a id1,id2,...] --start --end --json-out <dir>`(传 `-a` 时跳过清单翻页直接查;公司名 advertiserName 会缺失) | 逐账户 `stats` |
140
140
  | 多账户 × 多维度 Google 数据 | **全量账号**:`google-analysis-batch run`(省略 `-a`)**2~10 子集**:`google-analysis -a id1,id2,...`(自动转发 batch);**≥10 子集或需 resume**:`google-analysis-batch run -a id1,id2,...` | 外层 for-loop;先跑 `list-accounts -m Google` 再把 ID 拼进 `-a` |
141
141
  | 多系列诊断 | `ad campaigns --json-out <dir>` + node 读文件过滤 | 逐系列 `ad campaign-get` |
142
142
 
@@ -167,13 +167,19 @@ Windows 注意:部分 Agent 客户端通过 PowerShell / cmd 代执行命令
167
167
 
168
168
  ### P2 · 多账户余额扫描
169
169
 
170
- **一条命令**:
170
+ **全量巡检**:
171
171
  ```bash
172
172
  siluzan-tso balance-scan -m BingV2 --threshold-days 7 --json-out ./snap-p2
173
173
  # 可选:--min-balance 100 筛绝对余额;--target-days 60 算建议充值额
174
174
  ```
175
175
  按 `remainingDays` 升序输出;消耗过低的僵尸账户不纳入预警。
176
176
 
177
+ **已知 ID 子集**(轻量画像,仅看几个号余额/续航):
178
+ ```bash
179
+ siluzan-tso balance-scan -m Google -a id1,id2,id3 --json-out ./snap-p2-subset
180
+ ```
181
+ 跳过清单翻页,全部展示(不按阈值丢弃);`hitReason="none"` 表示该账户未触阈值。
182
+
177
183
  ### P3 · 多账户投放画像汇总
178
184
 
179
185
  ```bash
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.17-beta.2",
4
- "publishedAt": 1778231004836
3
+ "version": "1.1.18-beta.1",
4
+ "publishedAt": 1778315223329
5
5
  }
@@ -13,15 +13,17 @@
13
13
  | `google-analysis --sections overview`(Google 网关 `OverviewSectionData`) | **实时** | ✅ 可查当天 | 当天/今日消耗、当天高消耗账号排行 |
14
14
  | `google-analysis --sections campaign-types`(`types-summary`) | **实时** | ✅ 可查当天 | 当天系列类型分布 |
15
15
  | `google-analysis` 其他维度(`campaigns` / `keywords` / `devices` / ...) | 实时(受 Google Ads API 同步延迟影响) | 可查当天,但当天可能尚未结算 | 周期分析、报告 |
16
- | `stats`、`balance-scan` 的近 7 日消耗、`accounts-digest`、`list-accounts` 合并消耗(TSO `accountsoverview`) | **每日同步昨天** | 查今天会全为 0 | 历史回溯、巡检、余额续航估算(口径为"截至昨天") |
16
+ | `stats -m Google` / `balance-scan -m Google` / `accounts-digest -m Google` / `list-accounts -m Google` 合并消耗(TSO `account-spend-overview`,2026-05 起) | **后端自动分流**:窗口完全在历史 `database` 模式(含余额/状态/币种/账户名 + 当期消耗);窗口含今天 → `googleCombined` 模式(仅实时消耗,无余额/状态/币种/账户名) | 可查当天(含今天时切实时聚合) | 历史回溯、巡检、余额续航估算;含今天时也能给出实时消耗 |
17
+ | `stats` / `balance-scan` / `accounts-digest` / `list-accounts` 的 **TikTok / Yandex / BingV2 / Kwai** 合并消耗(TSO `accountsoverview`) | **每日同步昨天** | ❌ 查今天会全为 0 | 历史回溯、巡检、余额续航估算(口径为"截至昨天") |
17
18
  | `balance`(`GetMediaAccountInfo`) | 实时 | — | 仅当前余额,不反映消耗 |
18
19
 
19
20
  **选用规则**:
20
21
 
21
- - 「今天/当天/今日消耗」「实时消耗排行」 → `google-analysis(-batch) --sections overview`,`--start` / `--end` 都设为今天
22
- - 「最近 N 天消耗 / 周报 / 月报 / 余额续航」 → `stats` / `balance-scan` / `accounts-digest`,默认窗口截至昨天即可
23
- - **禁止**用 `accountsoverview` 系列接口(`stats` / `balance-scan` / `accounts-digest` / `list-accounts` 合并消耗)判断当天消耗
24
- - **禁止**给当天高消耗场景加 `--min-spend`:该预筛选同样来自 `accountsoverview`,会把今天有消耗的账号当成 0 给筛掉
22
+ - 「今天/当天/今日消耗」「实时消耗排行」 → 优先 `google-analysis(-batch) --sections overview`,`--start` / `--end` 都设为今天;
23
+ - Google 单账户/批量取数也可直接 `stats -m Google` `--end-date` 设为今天,后端会切到 `googleCombined` 模式给实时消耗(但**不会**返回余额/币种/账户名)。
24
+ - 「最近 N 天消耗 / 周报 / 月报 / 余额续航」 → `stats` / `balance-scan` / `accounts-digest`,默认窗口截至昨天即可(Google 此时走 `database`,包含完整字段)。
25
+ - **禁止**用 TikTok / Yandex / BingV2 / Kwai 的 `accountsoverview` 接口判断当天消耗(仍是每日同步)。
26
+ - **禁止**给非 Google 媒体的当天高消耗场景加 `--min-spend`:其预筛选来自非实时 `accountsoverview`,会把今天有消耗的账号当成 0 给筛掉。Google 媒体当 `--end-date` 设为今天时,预筛选走的是 `googleCombined` 实时数据,可以使用 `--min-spend`。
25
27
 
26
28
  ---
27
29
 
@@ -125,11 +125,18 @@ siluzan-tso balance -m Google -a 6326027735 --json
125
125
 
126
126
  **单户余额与续航**:`balance` 只反映当前余额;判断「还能跑几天 / 是否够花」需结合 `stats`(或业务侧日均消耗)等数据。向用户展示 JSON 时,`mediaCustomerId` 须与本次 `-a` 查询的 ID 及命令输出一致。
127
127
 
128
+ **少量账户的画像/续航估算**:直接用 `balance-scan -m <媒体> -a id1,id2`(子集模式)一条命令拿齐余额 + 近 7 日消耗 + 续航天数 + 充值建议,比 `balance` + `stats` 拼接更省事;或者用 `accounts-digest -m <媒体> -a id1,id2` 拿齐消耗 + CTR/CPC/CPA 派生指标 + 余额。两者都会跳过清单翻页,直接对指定 ID 拉数据。
129
+
128
130
  ---
129
131
 
130
- ## stats — 查询投放消耗数据(每日同步昨天数据)
132
+ ## stats — 查询投放消耗数据
131
133
 
132
- > **数据时效性**:本接口口径为 `accountsoverview`,每日凌晨同步昨天数据,**不能查今天**。判断「今天/当天/今日消耗」请走 `google-analysis(-batch) --sections overview`。完整时效性表见 `references/account-analytics.md` 顶部。
134
+ > **数据时效性**:
135
+ > - **Google**:走 `account-spend-overview`(2026-05 起),后端按日期窗口分流——
136
+ > - 窗口完全在历史 → `database` 模式:含余额、状态、币种、账户名、当期展点消等完整字段;
137
+ > - 窗口包含今天 → `googleCombined` 模式:只返回实时聚合的展点消(**没有**余额/状态/币种/账户名)。
138
+ > - **TikTok / Yandex / BingV2 / Kwai**:走旧版 `accountsoverview`,每日凌晨同步昨天数据,**不能查今天**。判断这几家的「今天/当天/今日消耗」仍需走 `google-analysis(-batch) --sections overview`(仅 Google)。
139
+ > - 完整时效性表见 `references/account-analytics.md` 顶部。
133
140
 
134
141
  ```bash
135
142
  siluzan-tso stats -m <媒体类型> [选项]
@@ -41,14 +41,46 @@ siluzan-tso init -d /path/to-your/skills # 写入自定义目录
41
41
  `siluzan-tso` 与 `siluzan-cso` **共用同一份凭据**,存储在 `~/.siluzan/config.json`,配置一次两个 CLI 均可使用。
42
42
 
43
43
  ```bash
44
- siluzan-tso login # 交互式登录,按提示创建 API Key 后粘贴
45
- siluzan-tso login --api-key <YOUR_API_KEY> # 直接设置 API Key(跳过交互)
46
- siluzan-tso config set --api-key <Key> # 或通过 config set 直接写入
47
- siluzan-tso config set --token <Token> # 备用:设置 JWT Token
44
+ siluzan-tso login # 交互式登录,按提示创建 API Key 后粘贴
45
+ siluzan-tso login --api-key <YOUR_API_KEY> # 直接设置 API Key(跳过交互)
46
+ siluzan-tso login --phone 138xxxx # 手机号 + 短信验证码登录(自动签发并保存 API Key)
47
+ siluzan-tso config set --api-key <Key> # 或通过 config set 直接写入
48
+ siluzan-tso config set --token <Token> # 备用:设置 JWT Token
48
49
  ```
49
50
 
50
51
  API Key 获取入口:`https://www-ci.siluzan.com/v3/foreign_trade/settings/apiKeyManagement`
51
52
 
53
+ ### 通过手机号 + 验证码登录(对话式 AI 推荐)
54
+
55
+ 适合让 AI 助手在对话里直接帮用户完成登录:用户报手机号 → AI 调 `siluzan-tso login --phone <号码>` → 用户报到的验证码 → AI 用 `--phone <号码> --code <验证码>` 一次性完成签发;签发成功的 API Key 会自动写入 `~/.siluzan/config.json`,后续命令直接复用。
56
+
57
+ ```bash
58
+ # 第 1 次调用:发送验证码(命令会进入交互式等输入验证码)
59
+ siluzan-tso login --phone 13800138000
60
+
61
+ # 第 2 次调用:用户告诉 AI 收到的验证码后,一次性把 phone+code 都传进去(AI 推荐用法)
62
+ siluzan-tso login --phone 13800138000 --code 123456
63
+
64
+ # 自定义自动创建的 API Key 名称、有效期、可访问的服务
65
+ siluzan-tso login --phone 13800138000 --code 123456 \
66
+ --name "Cursor - my-mac - 2026" \
67
+ --valid-days 30 \
68
+ --services TSO,CUT
69
+ ```
70
+
71
+ | 参数 | 说明 | 默认值 |
72
+ | ---------------- | --------------------------------------------------------------------------------- | ----------------------------------- |
73
+ | `--phone` | 11 位中国大陆手机号;触发短信验证码流程;**手机号必须已在丝路赞网页端注册** | 必填 |
74
+ | `--code` | 6 位短信验证码;不传则进入交互式提示输入 | 交互式输入 |
75
+ | `--name` | 自动创建的 API Key 显示名称 | `CLI - <hostname> - <yyyy-MM-dd>` |
76
+ | `--valid-days` | API Key 有效期(天),与 `--expires-at` 二选一 | `90` |
77
+ | `--expires-at` | API Key 绝对过期时间(ISO 8601) | 不传则用 `--valid-days` |
78
+ | `--services` | 可访问的服务列表,逗号分隔;可选 `CSO`/`TSO`/`CUT` | `TSO,CUT`(广告投放 + 素材中心) |
79
+ | `--verbose` | 输出每次 HTTP 请求的 URL,便于排错 | 关闭 |
80
+
81
+ > **未注册手机号** 会返回 `❌ 登录失败:手机未注册` 并附带网页注册地址,引导用户先去网页注册再回来重试。
82
+ > **AI 助手用法**:第一次只把 `--phone` 传进去触发发码;用户在对话里报了验证码后,**重新调用并同时传 `--phone` + `--code`**,避免被交互式 prompt 卡住。
83
+
52
84
  ### 通过环境变量传入凭据(CI/CD 推荐)
53
85
 
54
86
  无需写入 config.json,直接通过环境变量传入:
@@ -34,7 +34,7 @@
34
34
 
35
35
  **CLI 对应关系(近似,非同一接口):**
36
36
 
37
- - 单账户余额、消耗趋势:**`balance`**、**`stats`**(`accountsoverview`),需已知 `mediaCustomerId`。
37
+ - 单账户余额、消耗趋势:**`balance`**、**`stats`**(Google 走新版 `account-spend-overview`,含今天会切实时模式;其余媒体仍是 `accountsoverview`),需已知 `mediaCustomerId`。
38
38
  - 开户进度:**`account-history`**、**`open-account`** 系列。
39
39
  - **充值**:无 CLI,见 `references/finance.md`,用 `webUrl` 引导至充值页。
40
40
 
@@ -52,7 +52,7 @@
52
52
 
53
53
  **CLI 对应关系:**
54
54
 
55
- - 单账户一段时间消耗/展示/点击:**`stats -m <媒体> -a <mediaCustomerId>`**(`accountsoverview`),日期可用默认或后续若支持传参则对齐页面。
55
+ - 单账户一段时间消耗/展示/点击:**`stats -m <媒体> -a <mediaCustomerId>`**(Google 用 `account-spend-overview` 自动按窗口分流历史/实时;其余媒体仍是 `accountsoverview`),日期可用默认或后续若支持传参则对齐页面。
56
56
  - **图表级、多账户联动** 与 **`GetAccountDataOverview`**:**CLI 未封装**,需网页或后续扩展命令。
57
57
 
58
58
  ### 4. 服务推荐(`MoreFunctionSection`,部分首页布局显示)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siluzan-tso-cli",
3
- "version": "1.1.17-beta.2",
3
+ "version": "1.1.18-beta.1",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",