siluzan-tso-cli 1.1.11-beta.7 → 1.1.12
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 +1 -2
- package/dist/index.js +622 -26
- package/dist/skill/SKILL.md +146 -2
- package/dist/skill/_meta.json +2 -2
- package/dist/skill/references/account-analytics.md +33 -6
- package/dist/skill/references/finance.md +5 -5
- package/dist/skill/references/reporting.md +2 -2
- package/dist/skill/references/setup.md +5 -5
- package/dist/skill/references/tso-home.md +1 -1
- package/dist/skill/scripts/install.ps1 +2 -2
- package/dist/skill/scripts/install.sh +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ HTML 报告模板引用以下 CDN:`cdn.tailwindcss.com`、`cdnjs.cloudflare.co
|
|
|
41
41
|
在**用户的目标项目根目录**执行(根据用户使用的助手选择 `--ai`):
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
npm install -g siluzan-tso-cli
|
|
44
|
+
npm install -g siluzan-tso-cli
|
|
45
45
|
siluzan-tso init --ai cursor # 写入 Cursor(默认)
|
|
46
46
|
siluzan-tso init --ai cursor,claude # 同时写入多个平台
|
|
47
47
|
siluzan-tso init --ai all # 写入所有支持的平台
|
|
@@ -49,7 +49,6 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
|
|
|
49
49
|
siluzan-tso init --force # 强制覆盖已存在文件
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
> **注意**:当前为测试版(1.1.11-beta.7),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
|
|
53
52
|
|
|
54
53
|
| 助手 | 建议 `--ai` |
|
|
55
54
|
|------|-------------|
|
package/dist/index.js
CHANGED
|
@@ -1962,7 +1962,7 @@ import { fileURLToPath as fileURLToPath4 } from "url";
|
|
|
1962
1962
|
import { Command } from "commander";
|
|
1963
1963
|
|
|
1964
1964
|
// src/config/defaults.ts
|
|
1965
|
-
var DEFAULT_API_BASE = "https://tso-api
|
|
1965
|
+
var DEFAULT_API_BASE = "https://tso-api.siluzan.com";
|
|
1966
1966
|
|
|
1967
1967
|
// ../common/dist/index.js
|
|
1968
1968
|
import * as fs from "fs";
|
|
@@ -2257,17 +2257,29 @@ async function fetchDataPermission(mainApiUrl, auth) {
|
|
|
2257
2257
|
return "";
|
|
2258
2258
|
}
|
|
2259
2259
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2260
|
+
var DATA_PERMISSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
2261
|
+
async function ensureDataPermission(config, opts = {}) {
|
|
2262
2262
|
if (!config.mainApiUrl) return config;
|
|
2263
|
+
const shared = readSharedConfig();
|
|
2264
|
+
const writtenAt = shared._dataPermissionWrittenAt ? new Date(shared._dataPermissionWrittenAt).getTime() : 0;
|
|
2265
|
+
const isStale = !writtenAt || Date.now() - writtenAt > DATA_PERMISSION_TTL_MS;
|
|
2266
|
+
if (!opts.force && config.dataPermission?.trim() && !isStale) {
|
|
2267
|
+
return config;
|
|
2268
|
+
}
|
|
2263
2269
|
const dp = await fetchDataPermission(config.mainApiUrl, config);
|
|
2264
2270
|
if (!dp) return config;
|
|
2265
2271
|
try {
|
|
2266
|
-
writeSharedConfig({
|
|
2272
|
+
writeSharedConfig({
|
|
2273
|
+
dataPermission: dp,
|
|
2274
|
+
_dataPermissionWrittenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2275
|
+
});
|
|
2267
2276
|
} catch {
|
|
2268
2277
|
}
|
|
2269
2278
|
return { ...config, dataPermission: dp };
|
|
2270
2279
|
}
|
|
2280
|
+
async function refreshDataPermission(config) {
|
|
2281
|
+
return ensureDataPermission(config, { force: true });
|
|
2282
|
+
}
|
|
2271
2283
|
function loadConfig(tokenArg) {
|
|
2272
2284
|
const shared = readSharedConfig();
|
|
2273
2285
|
const apiKey = process.env.SILUZAN_API_KEY ?? (shared.apiKey ? shared.apiKey : void 0);
|
|
@@ -2628,10 +2640,16 @@ function readConfigRaw() {
|
|
|
2628
2640
|
return {};
|
|
2629
2641
|
}
|
|
2630
2642
|
}
|
|
2631
|
-
function
|
|
2643
|
+
function mergeWriteConfig(updates) {
|
|
2632
2644
|
try {
|
|
2633
2645
|
fs5.mkdirSync(path5.dirname(CONFIG_FILE2), { recursive: true });
|
|
2634
|
-
|
|
2646
|
+
let existing = {};
|
|
2647
|
+
try {
|
|
2648
|
+
existing = JSON.parse(fs5.readFileSync(CONFIG_FILE2, "utf8"));
|
|
2649
|
+
} catch {
|
|
2650
|
+
}
|
|
2651
|
+
Object.assign(existing, updates);
|
|
2652
|
+
fs5.writeFileSync(CONFIG_FILE2, JSON.stringify(existing, null, 2), "utf8");
|
|
2635
2653
|
if (process.platform !== "win32") {
|
|
2636
2654
|
fs5.chmodSync(CONFIG_FILE2, 384);
|
|
2637
2655
|
}
|
|
@@ -2661,17 +2679,17 @@ async function notifyIfOutdated() {
|
|
|
2661
2679
|
fetchVersionByTag(tag, latestCacheKey, cfg),
|
|
2662
2680
|
fetchVersionByTag(minTag, minCacheKey, cfg)
|
|
2663
2681
|
]);
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2682
|
+
const cacheUpdates = {
|
|
2683
|
+
_tsoLastVersionCheck: (/* @__PURE__ */ new Date()).toISOString()
|
|
2684
|
+
};
|
|
2685
|
+
if (latest) cacheUpdates[latestCacheKey] = latest;
|
|
2686
|
+
if (minRequired) cacheUpdates[minCacheKey] = minRequired;
|
|
2687
|
+
mergeWriteConfig(cacheUpdates);
|
|
2670
2688
|
const lastNotified = cfg._tsoLastNotified ? new Date(cfg._tsoLastNotified).getTime() : 0;
|
|
2671
2689
|
if (Date.now() - lastNotified < 24 * 60 * 60 * 1e3) return;
|
|
2672
2690
|
const tagLabel = isBeta ? "\uFF08\u6D4B\u8BD5\u7248\uFF09" : "\uFF08\u6B63\u5F0F\u7248\uFF09";
|
|
2673
2691
|
if (minRequired && isNewer(current, minRequired)) {
|
|
2674
|
-
|
|
2692
|
+
mergeWriteConfig({ _tsoLastNotified: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2675
2693
|
console.log(
|
|
2676
2694
|
`
|
|
2677
2695
|
[SILUZAN_FORCE_UPDATE] \u5F53\u524D\u7248\u672C ${current} \u5DF2\u8FC7\u671F\uFF0C\u6700\u4F4E\u8981\u6C42\u7248\u672C ${minRequired}${tagLabel}
|
|
@@ -2682,7 +2700,7 @@ async function notifyIfOutdated() {
|
|
|
2682
2700
|
return;
|
|
2683
2701
|
}
|
|
2684
2702
|
if (latest && isNewer(current, latest)) {
|
|
2685
|
-
|
|
2703
|
+
mergeWriteConfig({ _tsoLastNotified: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2686
2704
|
console.log(
|
|
2687
2705
|
`
|
|
2688
2706
|
[SILUZAN_UPDATE_AVAILABLE] \u53D1\u73B0\u65B0\u7248\u672C ${latest}${tagLabel}\uFF08\u5F53\u524D ${current}\uFF09
|
|
@@ -3024,7 +3042,10 @@ async function fetchTikTokAccountByMediaCustomerId(config, mediaCustomerId, verb
|
|
|
3024
3042
|
return match ?? null;
|
|
3025
3043
|
}
|
|
3026
3044
|
async function runListAccounts(opts) {
|
|
3027
|
-
|
|
3045
|
+
let config = loadConfig(opts.token);
|
|
3046
|
+
if (opts.refreshDp) {
|
|
3047
|
+
config = await refreshDataPermission(config);
|
|
3048
|
+
}
|
|
3028
3049
|
if (opts.media && !VALID_MEDIA_TYPES.includes(opts.media)) {
|
|
3029
3050
|
console.error(
|
|
3030
3051
|
`
|
|
@@ -3180,15 +3201,23 @@ async function runListAccounts(opts) {
|
|
|
3180
3201
|
}
|
|
3181
3202
|
}
|
|
3182
3203
|
}
|
|
3204
|
+
const oauthFailWarning = (() => {
|
|
3205
|
+
if (items.length < 5) return null;
|
|
3206
|
+
const allFail = items.every((it) => it.ma?.invalidOAuthToken === true);
|
|
3207
|
+
return allFail ? "\u68C0\u6D4B\u5230\u672C\u9875\u6240\u6709\u8D26\u6237\u5747\u6807\u8BB0\u4E3A OAuth \u5931\u6548\uFF0C\u8FD9\u901A\u5E38\u662F\u670D\u52A1\u7AEF\u4F1A\u8BDD\u5F02\u5E38\u6216\u6743\u9650\u6F02\u79FB\uFF08\u975E\u771F\u5931\u6548\uFF09\u3002\u5EFA\u8BAE\u6309\u987A\u5E8F\u6392\u67E5\uFF1A\n 1) siluzan-tso list-accounts --refresh-dp # \u5F3A\u5236\u5237\u65B0 Datapermission\n 2) siluzan-tso login # \u91CD\u65B0\u767B\u5F55\u83B7\u53D6\u65B0 authToken\n 3) 1 \u5206\u949F\u540E\u91CD\u8BD5\uFF1B\u82E5\u4ECD\u590D\u73B0\uFF0C\u8BF7\u5E26\u4E0A --verbose \u8F93\u51FA\u53CD\u9988\u7ED9\u5E73\u53F0\u3002" : null;
|
|
3208
|
+
})();
|
|
3183
3209
|
if (opts.json) {
|
|
3184
3210
|
console.log(
|
|
3185
3211
|
JSON.stringify(
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3212
|
+
{
|
|
3213
|
+
...wrapListJson({
|
|
3214
|
+
page,
|
|
3215
|
+
pageSize,
|
|
3216
|
+
total: total ?? null,
|
|
3217
|
+
items
|
|
3218
|
+
}),
|
|
3219
|
+
...oauthFailWarning ? { warning: oauthFailWarning } : {}
|
|
3220
|
+
},
|
|
3192
3221
|
null,
|
|
3193
3222
|
2
|
|
3194
3223
|
)
|
|
@@ -3237,6 +3266,10 @@ async function runListAccounts(opts) {
|
|
|
3237
3266
|
\u2139\uFE0F \u6B64\u5217\u8868\u4E0D\u663E\u793A Google \u5C01\u53F7\uFF08Suspended\uFF09\u72B6\u6001\uFF0C\u5982\u9700\u67E5\u770B\u53EF\u63D0\u73B0\u7684\u88AB\u5C01\u8D26\u6237\u8BF7\u8FD0\u884C\uFF1A`);
|
|
3238
3267
|
console.log(` siluzan-tso account withdraw-list`);
|
|
3239
3268
|
}
|
|
3269
|
+
if (oauthFailWarning) {
|
|
3270
|
+
console.log(`
|
|
3271
|
+
\u26A0\uFE0F ${oauthFailWarning}`);
|
|
3272
|
+
}
|
|
3240
3273
|
console.log();
|
|
3241
3274
|
}
|
|
3242
3275
|
function fmtNum(v, decimals = 2) {
|
|
@@ -3695,6 +3728,500 @@ ${media} \u8D26\u6237\u4F59\u989D\uFF08\u7B2C 1 \u9875\uFF0C\u672C\u9875 ${out.l
|
|
|
3695
3728
|
console.log();
|
|
3696
3729
|
}
|
|
3697
3730
|
|
|
3731
|
+
// src/commands/balance-scan.ts
|
|
3732
|
+
var SCAN_PLATFORM_CONFIG = {
|
|
3733
|
+
Google: {
|
|
3734
|
+
path: "/query/media-account/",
|
|
3735
|
+
pageParam: "pageNo",
|
|
3736
|
+
fixedParams: { MediaType: "Google", mediaAccountState: "Approved,Linked" },
|
|
3737
|
+
responseType: "array"
|
|
3738
|
+
},
|
|
3739
|
+
Yandex: {
|
|
3740
|
+
path: "/query/media-account/",
|
|
3741
|
+
pageParam: "pageNo",
|
|
3742
|
+
fixedParams: { MediaType: "Yandex", mediaAccountState: "Approved,Linked" },
|
|
3743
|
+
responseType: "array"
|
|
3744
|
+
},
|
|
3745
|
+
TikTok: {
|
|
3746
|
+
path: "/query/media-account/tiktok/SearchMediaAcountByCriteria",
|
|
3747
|
+
pageParam: "pageNum",
|
|
3748
|
+
fixedParams: {
|
|
3749
|
+
MediaType: "TikTok",
|
|
3750
|
+
advStatus: "STATUS_ENABLE,STATUS_LIMIT,STATUS_DISABLE",
|
|
3751
|
+
mediaAccountState: "Approved,Linked",
|
|
3752
|
+
isForce: false
|
|
3753
|
+
},
|
|
3754
|
+
responseType: "mas"
|
|
3755
|
+
},
|
|
3756
|
+
BingV2: {
|
|
3757
|
+
path: "/query/media-account/BingV2/SearchBingV2MediaAcountByUserId",
|
|
3758
|
+
pageParam: "pageNum",
|
|
3759
|
+
fixedParams: {
|
|
3760
|
+
MediaType: "BingV2",
|
|
3761
|
+
advStatus: "APPROVED",
|
|
3762
|
+
mediaAccountState: "Approved,Linked",
|
|
3763
|
+
isForce: false
|
|
3764
|
+
},
|
|
3765
|
+
responseType: "mas"
|
|
3766
|
+
},
|
|
3767
|
+
Kwai: {
|
|
3768
|
+
path: "/query/media-account/SearchMediaAcountByCriteria",
|
|
3769
|
+
pageParam: "pageNum",
|
|
3770
|
+
fixedParams: {
|
|
3771
|
+
MediaTypes: "Kwai",
|
|
3772
|
+
MediaType: "Kwai",
|
|
3773
|
+
advStatus: "",
|
|
3774
|
+
mediaAccountState: "Approved,Linked",
|
|
3775
|
+
isForce: false
|
|
3776
|
+
},
|
|
3777
|
+
responseType: "mas"
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
async function fetchOnePage(media, page, pageSize, config, verbose) {
|
|
3781
|
+
const cfg = SCAN_PLATFORM_CONFIG[media];
|
|
3782
|
+
if (!cfg) throw new Error(`balance scan \u6682\u4E0D\u652F\u6301\u5A92\u4F53\uFF1A${media}`);
|
|
3783
|
+
const params = new URLSearchParams();
|
|
3784
|
+
for (const [k, v] of Object.entries(cfg.fixedParams)) {
|
|
3785
|
+
params.set(k, String(v));
|
|
3786
|
+
}
|
|
3787
|
+
params.set(cfg.pageParam, String(page));
|
|
3788
|
+
params.set("pageSize", String(pageSize));
|
|
3789
|
+
const url = `${config.apiBaseUrl}${cfg.path}?${params}`;
|
|
3790
|
+
if (cfg.responseType === "array") {
|
|
3791
|
+
const res2 = await apiFetchWithHeaders2(url, config, {}, verbose);
|
|
3792
|
+
const items = res2.data ?? [];
|
|
3793
|
+
const hit = res2.headers["s-total-hits"];
|
|
3794
|
+
const total = hit !== void 0 ? parseInt(hit, 10) || void 0 : void 0;
|
|
3795
|
+
return { items, total };
|
|
3796
|
+
}
|
|
3797
|
+
const res = await apiFetch2(url, config, {}, verbose);
|
|
3798
|
+
return {
|
|
3799
|
+
items: Array.isArray(res?.mas) ? res.mas : [],
|
|
3800
|
+
total: typeof res?.total === "number" ? res.total : void 0
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
async function runBalanceScan(opts) {
|
|
3804
|
+
let config = loadConfig(opts.token);
|
|
3805
|
+
if (opts.refreshDp) {
|
|
3806
|
+
config = await refreshDataPermission(config);
|
|
3807
|
+
}
|
|
3808
|
+
if (!BALANCE_SUPPORTED_MEDIA.includes(opts.media)) {
|
|
3809
|
+
console.error(
|
|
3810
|
+
`
|
|
3811
|
+
\u274C balance scan \u6682\u4E0D\u652F\u6301\u5A92\u4F53\uFF1A${opts.media}
|
|
3812
|
+
\u53EF\u9009\uFF1A${BALANCE_SUPPORTED_MEDIA.join(" | ")}\uFF08MetaAd \u63A5\u53E3\u672A\u5F00\u653E\u4F59\u989D\u67E5\u8BE2\uFF09
|
|
3813
|
+
`
|
|
3814
|
+
);
|
|
3815
|
+
process.exit(1);
|
|
3816
|
+
}
|
|
3817
|
+
const media = opts.media;
|
|
3818
|
+
const thresholdDays = opts.thresholdDays ?? 7;
|
|
3819
|
+
const minBalance = opts.minBalance ?? null;
|
|
3820
|
+
const minDailySpend = opts.minDailySpend ?? 0.01;
|
|
3821
|
+
const targetDays = opts.targetDays ?? 30;
|
|
3822
|
+
const pageSize = Math.max(1, Math.min(500, opts.pageSize ?? 200));
|
|
3823
|
+
const maxPages = Math.max(1, Math.min(200, opts.maxPages ?? 20));
|
|
3824
|
+
const allItems = [];
|
|
3825
|
+
let total;
|
|
3826
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
3827
|
+
process.stderr.write(`\r\u23F3 \u626B\u63CF\u4E2D\uFF1A\u7B2C ${page} \u9875\uFF08\u5DF2\u7D2F\u8BA1 ${allItems.length} \u4E2A\u8D26\u6237\uFF09...`);
|
|
3828
|
+
let pageRes;
|
|
3829
|
+
try {
|
|
3830
|
+
pageRes = await fetchOnePage(media, page, pageSize, config, opts.verbose);
|
|
3831
|
+
} catch (err) {
|
|
3832
|
+
process.stderr.write("\n");
|
|
3833
|
+
console.error(`
|
|
3834
|
+
\u274C \u62C9\u53D6\u8D26\u6237\u6E05\u5355\u5931\u8D25\uFF08\u7B2C ${page} \u9875\uFF09\uFF1A${err instanceof Error ? err.message : String(err)}
|
|
3835
|
+
`);
|
|
3836
|
+
process.exit(1);
|
|
3837
|
+
}
|
|
3838
|
+
allItems.push(...pageRes.items);
|
|
3839
|
+
if (total === void 0 && pageRes.total !== void 0) total = pageRes.total;
|
|
3840
|
+
const fetched = allItems.length;
|
|
3841
|
+
const knownTotal = pageRes.total ?? total;
|
|
3842
|
+
if (pageRes.items.length < pageSize) break;
|
|
3843
|
+
if (knownTotal !== void 0 && fetched >= knownTotal) break;
|
|
3844
|
+
}
|
|
3845
|
+
process.stderr.write("\n");
|
|
3846
|
+
const validIds = [];
|
|
3847
|
+
for (const item of allItems) {
|
|
3848
|
+
const id = item.ma?.mediaCustomerId;
|
|
3849
|
+
if (id && !item.ma?.invalidOAuthToken) validIds.push(String(id));
|
|
3850
|
+
}
|
|
3851
|
+
let balanceMap = /* @__PURE__ */ new Map();
|
|
3852
|
+
let overviewMap = /* @__PURE__ */ new Map();
|
|
3853
|
+
if (validIds.length > 0) {
|
|
3854
|
+
const CHUNK = 100;
|
|
3855
|
+
const chunks = [];
|
|
3856
|
+
for (let i = 0; i < validIds.length; i += CHUNK) chunks.push(validIds.slice(i, i + CHUNK));
|
|
3857
|
+
const [bMaps, oMaps] = await Promise.all([
|
|
3858
|
+
Promise.all(chunks.map((ids) => fetchBalanceMap(media, ids, config, void 0, void 0, opts.verbose))),
|
|
3859
|
+
Promise.all(chunks.map((ids) => fetchOverviewMap(media, ids, config, void 0, void 0, opts.verbose)))
|
|
3860
|
+
]);
|
|
3861
|
+
for (const m of bMaps) for (const [k, v] of m) balanceMap.set(k, v);
|
|
3862
|
+
for (const m of oMaps) for (const [k, v] of m) overviewMap.set(k, v);
|
|
3863
|
+
}
|
|
3864
|
+
const evaluated = [];
|
|
3865
|
+
for (const item of allItems) {
|
|
3866
|
+
const ma = item.ma;
|
|
3867
|
+
const id = ma?.mediaCustomerId ? String(ma.mediaCustomerId) : "";
|
|
3868
|
+
if (!id) continue;
|
|
3869
|
+
const bal = balanceMap.get(id);
|
|
3870
|
+
const ov = overviewMap.get(id);
|
|
3871
|
+
const balance = bal?.remainingAccountBudget ?? null;
|
|
3872
|
+
const sevenDaySpend = Number(ov?.spend ?? 0);
|
|
3873
|
+
const dailySpend = sevenDaySpend > 0 ? sevenDaySpend / 7 : 0;
|
|
3874
|
+
let remainingDays = null;
|
|
3875
|
+
let recommendedTopup = null;
|
|
3876
|
+
if (dailySpend >= minDailySpend && balance !== null) {
|
|
3877
|
+
remainingDays = +(balance / dailySpend).toFixed(1);
|
|
3878
|
+
const needed = dailySpend * targetDays - balance;
|
|
3879
|
+
recommendedTopup = needed > 0 ? +needed.toFixed(2) : 0;
|
|
3880
|
+
}
|
|
3881
|
+
const lowDays = remainingDays !== null && remainingDays <= thresholdDays;
|
|
3882
|
+
const lowBalance = minBalance !== null && balance !== null && balance <= minBalance;
|
|
3883
|
+
if (!lowDays && !lowBalance) continue;
|
|
3884
|
+
const hitReason = lowDays && lowBalance ? "both" : lowDays ? "low-days" : "low-balance";
|
|
3885
|
+
evaluated.push({
|
|
3886
|
+
mediaCustomerId: id,
|
|
3887
|
+
name: ma.mediaCustomerName ?? null,
|
|
3888
|
+
advertiserName: item.mag?.advertiserName ?? null,
|
|
3889
|
+
currencyCode: bal?.currencyCode ?? ma.currencyCode ?? null,
|
|
3890
|
+
balance,
|
|
3891
|
+
dailySpend: dailySpend > 0 ? +dailySpend.toFixed(2) : null,
|
|
3892
|
+
remainingDays,
|
|
3893
|
+
recommendedTopup,
|
|
3894
|
+
invalidOAuthToken: !!ma.invalidOAuthToken,
|
|
3895
|
+
status: bal?.status ?? bal?.accountStatus ?? null,
|
|
3896
|
+
hitReason
|
|
3897
|
+
});
|
|
3898
|
+
}
|
|
3899
|
+
evaluated.sort((a, b) => {
|
|
3900
|
+
const ad = a.remainingDays ?? Number.POSITIVE_INFINITY;
|
|
3901
|
+
const bd = b.remainingDays ?? Number.POSITIVE_INFINITY;
|
|
3902
|
+
return ad - bd;
|
|
3903
|
+
});
|
|
3904
|
+
const meta = {
|
|
3905
|
+
media,
|
|
3906
|
+
scannedAccounts: allItems.length,
|
|
3907
|
+
validAccounts: validIds.length,
|
|
3908
|
+
skippedInvalidOAuth: allItems.length - validIds.length,
|
|
3909
|
+
hitCount: evaluated.length,
|
|
3910
|
+
thresholds: {
|
|
3911
|
+
days: thresholdDays,
|
|
3912
|
+
minBalance,
|
|
3913
|
+
minDailySpend,
|
|
3914
|
+
targetDaysForTopup: targetDays
|
|
3915
|
+
},
|
|
3916
|
+
pageSize,
|
|
3917
|
+
maxPages,
|
|
3918
|
+
totalReported: total ?? null,
|
|
3919
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3920
|
+
};
|
|
3921
|
+
if (opts.json) {
|
|
3922
|
+
console.log(JSON.stringify({ ok: true, data: { items: evaluated }, meta }, null, 2));
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
console.log(
|
|
3926
|
+
`
|
|
3927
|
+
${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
|
|
3928
|
+
`
|
|
3929
|
+
);
|
|
3930
|
+
if (evaluated.length === 0) {
|
|
3931
|
+
console.log(" \u2705 \u65E0\u547D\u4E2D\u8D26\u6237\u3002\n");
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
const cols = [
|
|
3935
|
+
{ key: "id", header: "\u8D26\u6237ID" },
|
|
3936
|
+
{ key: "company", header: "\u516C\u53F8" },
|
|
3937
|
+
{ key: "name", header: "\u8D26\u6237\u540D" },
|
|
3938
|
+
{ key: "balance", header: "\u4F59\u989D" },
|
|
3939
|
+
{ key: "daily", header: "\u65E5\u5747\u6D88\u8017(\u8FD17\u65E5)" },
|
|
3940
|
+
{ key: "days", header: "\u5269\u4F59\u5929\u6570" },
|
|
3941
|
+
{ key: "topup", header: `\u5EFA\u8BAE\u5145\u503C(\u76EE\u6807${targetDays}\u5929)` },
|
|
3942
|
+
{ key: "reason", header: "\u547D\u4E2D\u539F\u56E0" }
|
|
3943
|
+
];
|
|
3944
|
+
const reasonText = {
|
|
3945
|
+
"low-days": `\u7EED\u822A \u2264 ${thresholdDays} \u5929`,
|
|
3946
|
+
"low-balance": `\u4F59\u989D \u2264 ${minBalance ?? 0}`,
|
|
3947
|
+
both: `\u7EED\u822A + \u4F59\u989D\u53CC\u4F4E`
|
|
3948
|
+
};
|
|
3949
|
+
const fmtMoney = (v, cur) => v == null ? "-" : `${cur ?? ""} ${v.toFixed(2)}`.trim();
|
|
3950
|
+
const rows = evaluated.map((x) => ({
|
|
3951
|
+
id: x.mediaCustomerId,
|
|
3952
|
+
company: x.advertiserName ?? "-",
|
|
3953
|
+
name: x.name ?? "-",
|
|
3954
|
+
balance: fmtMoney(x.balance, x.currencyCode),
|
|
3955
|
+
daily: fmtMoney(x.dailySpend, x.currencyCode),
|
|
3956
|
+
days: x.remainingDays == null ? "\u2014\uFF08\u6D88\u8017\u8FC7\u4F4E\uFF09" : String(x.remainingDays),
|
|
3957
|
+
topup: fmtMoney(x.recommendedTopup, x.currencyCode),
|
|
3958
|
+
reason: reasonText[x.hitReason]
|
|
3959
|
+
}));
|
|
3960
|
+
printCliTable(rows, cols);
|
|
3961
|
+
console.log(
|
|
3962
|
+
`
|
|
3963
|
+
\u63D0\u793A\uFF1A\u4E0A\u8868\u5DF2\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\u6392\u5E8F\u3002
|
|
3964
|
+
- \u5982\u9700\u8C03\u6574\u9608\u503C\uFF1A--threshold-days 14 / --min-balance 500
|
|
3965
|
+
- \u5982\u9700\u8C03\u6574\u5EFA\u8BAE\u5145\u503C\u76EE\u6807\u7EED\u822A\uFF1A--target-days 60
|
|
3966
|
+
- \u5982\u9700\u5BFC\u51FA\u7ED3\u6784\u5316\u6570\u636E\uFF1A\u52A0 --json\uFF08\u542B meta \u6C47\u603B\uFF09
|
|
3967
|
+
`
|
|
3968
|
+
);
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
// src/commands/accounts-digest.ts
|
|
3972
|
+
var DIGEST_PLATFORM_CONFIG = {
|
|
3973
|
+
Google: {
|
|
3974
|
+
path: "/query/media-account/",
|
|
3975
|
+
pageParam: "pageNo",
|
|
3976
|
+
fixedParams: { MediaType: "Google", mediaAccountState: "Approved,Linked" },
|
|
3977
|
+
responseType: "array"
|
|
3978
|
+
},
|
|
3979
|
+
Yandex: {
|
|
3980
|
+
path: "/query/media-account/",
|
|
3981
|
+
pageParam: "pageNo",
|
|
3982
|
+
fixedParams: { MediaType: "Yandex", mediaAccountState: "Approved,Linked" },
|
|
3983
|
+
responseType: "array"
|
|
3984
|
+
},
|
|
3985
|
+
TikTok: {
|
|
3986
|
+
path: "/query/media-account/tiktok/SearchMediaAcountByCriteria",
|
|
3987
|
+
pageParam: "pageNum",
|
|
3988
|
+
fixedParams: {
|
|
3989
|
+
MediaType: "TikTok",
|
|
3990
|
+
advStatus: "STATUS_ENABLE,STATUS_LIMIT,STATUS_DISABLE",
|
|
3991
|
+
mediaAccountState: "Approved,Linked",
|
|
3992
|
+
isForce: false
|
|
3993
|
+
},
|
|
3994
|
+
responseType: "mas"
|
|
3995
|
+
},
|
|
3996
|
+
BingV2: {
|
|
3997
|
+
path: "/query/media-account/BingV2/SearchBingV2MediaAcountByUserId",
|
|
3998
|
+
pageParam: "pageNum",
|
|
3999
|
+
fixedParams: {
|
|
4000
|
+
MediaType: "BingV2",
|
|
4001
|
+
advStatus: "APPROVED",
|
|
4002
|
+
mediaAccountState: "Approved,Linked",
|
|
4003
|
+
isForce: false
|
|
4004
|
+
},
|
|
4005
|
+
responseType: "mas"
|
|
4006
|
+
},
|
|
4007
|
+
Kwai: {
|
|
4008
|
+
path: "/query/media-account/SearchMediaAcountByCriteria",
|
|
4009
|
+
pageParam: "pageNum",
|
|
4010
|
+
fixedParams: {
|
|
4011
|
+
MediaTypes: "Kwai",
|
|
4012
|
+
MediaType: "Kwai",
|
|
4013
|
+
advStatus: "",
|
|
4014
|
+
mediaAccountState: "Approved,Linked",
|
|
4015
|
+
isForce: false
|
|
4016
|
+
},
|
|
4017
|
+
responseType: "mas"
|
|
4018
|
+
}
|
|
4019
|
+
};
|
|
4020
|
+
async function fetchAccountsByMedia(media, config, opts) {
|
|
4021
|
+
const cfg = DIGEST_PLATFORM_CONFIG[media];
|
|
4022
|
+
if (!cfg) throw new Error(`accounts-digest \u6682\u4E0D\u652F\u6301\u5A92\u4F53\uFF1A${media}`);
|
|
4023
|
+
const all = [];
|
|
4024
|
+
let total;
|
|
4025
|
+
for (let page = 1; page <= opts.maxPages; page++) {
|
|
4026
|
+
process.stderr.write(`\r\u23F3 \u62C9\u53D6\u8D26\u6237\u6E05\u5355\uFF1A\u7B2C ${page} \u9875\uFF08\u7D2F\u8BA1 ${all.length}\uFF09...`);
|
|
4027
|
+
const params = new URLSearchParams();
|
|
4028
|
+
for (const [k, v] of Object.entries(cfg.fixedParams)) params.set(k, String(v));
|
|
4029
|
+
params.set(cfg.pageParam, String(page));
|
|
4030
|
+
params.set("pageSize", String(opts.pageSize));
|
|
4031
|
+
const url = `${config.apiBaseUrl}${cfg.path}?${params}`;
|
|
4032
|
+
if (cfg.responseType === "array") {
|
|
4033
|
+
const res = await apiFetchWithHeaders2(url, config, {}, opts.verbose);
|
|
4034
|
+
const pageItems = res.data ?? [];
|
|
4035
|
+
all.push(...pageItems);
|
|
4036
|
+
if (total === void 0) {
|
|
4037
|
+
const hit = res.headers["s-total-hits"];
|
|
4038
|
+
if (hit !== void 0) total = parseInt(hit, 10) || void 0;
|
|
4039
|
+
}
|
|
4040
|
+
if (pageItems.length < opts.pageSize) break;
|
|
4041
|
+
if (total !== void 0 && all.length >= total) break;
|
|
4042
|
+
} else {
|
|
4043
|
+
const res = await apiFetch2(url, config, {}, opts.verbose);
|
|
4044
|
+
const pageItems = Array.isArray(res?.mas) ? res.mas : [];
|
|
4045
|
+
all.push(...pageItems);
|
|
4046
|
+
if (total === void 0 && typeof res?.total === "number") total = res.total;
|
|
4047
|
+
if (pageItems.length < opts.pageSize) break;
|
|
4048
|
+
if (total !== void 0 && all.length >= total) break;
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
process.stderr.write("\n");
|
|
4052
|
+
return { items: all, total };
|
|
4053
|
+
}
|
|
4054
|
+
function round2(n) {
|
|
4055
|
+
if (n == null) return null;
|
|
4056
|
+
const v = Number(n);
|
|
4057
|
+
return Number.isFinite(v) ? +v.toFixed(2) : null;
|
|
4058
|
+
}
|
|
4059
|
+
async function runAccountsDigest(opts) {
|
|
4060
|
+
let config = loadConfig(opts.token);
|
|
4061
|
+
if (opts.refreshDp) config = await refreshDataPermission(config);
|
|
4062
|
+
if (!BALANCE_SUPPORTED_MEDIA.includes(opts.media)) {
|
|
4063
|
+
console.error(
|
|
4064
|
+
`
|
|
4065
|
+
\u274C accounts-digest \u6682\u4E0D\u652F\u6301\u5A92\u4F53\uFF1A${opts.media}
|
|
4066
|
+
\u53EF\u9009\uFF1A${BALANCE_SUPPORTED_MEDIA.join(" | ")}\uFF08MetaAd \u65E0\u4F59\u989D/\u6D88\u8017\u6C47\u603B\u63A5\u53E3\uFF09
|
|
4067
|
+
`
|
|
4068
|
+
);
|
|
4069
|
+
process.exit(1);
|
|
4070
|
+
}
|
|
4071
|
+
const media = opts.media;
|
|
4072
|
+
const filterIds = (opts.accounts ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
4073
|
+
const filterSet = new Set(filterIds);
|
|
4074
|
+
const minSpend = opts.minSpend ?? 0;
|
|
4075
|
+
const pageSize = Math.max(1, Math.min(500, opts.pageSize ?? 200));
|
|
4076
|
+
const maxPages = Math.max(1, Math.min(200, opts.maxPages ?? 20));
|
|
4077
|
+
let accountsList;
|
|
4078
|
+
try {
|
|
4079
|
+
const res = await fetchAccountsByMedia(media, config, { pageSize, maxPages, verbose: opts.verbose });
|
|
4080
|
+
accountsList = res.items;
|
|
4081
|
+
} catch (err) {
|
|
4082
|
+
console.error(`
|
|
4083
|
+
\u274C \u62C9\u53D6\u8D26\u6237\u6E05\u5355\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
|
|
4084
|
+
`);
|
|
4085
|
+
process.exit(1);
|
|
4086
|
+
}
|
|
4087
|
+
if (filterSet.size > 0) {
|
|
4088
|
+
accountsList = accountsList.filter((it) => {
|
|
4089
|
+
const id = it.ma?.mediaCustomerId;
|
|
4090
|
+
return id != null && filterSet.has(String(id));
|
|
4091
|
+
});
|
|
4092
|
+
}
|
|
4093
|
+
const validIds = accountsList.filter((it) => it.ma?.mediaCustomerId && !it.ma?.invalidOAuthToken).map((it) => String(it.ma.mediaCustomerId));
|
|
4094
|
+
const CHUNK = 100;
|
|
4095
|
+
const chunks = [];
|
|
4096
|
+
for (let i = 0; i < validIds.length; i += CHUNK) chunks.push(validIds.slice(i, i + CHUNK));
|
|
4097
|
+
const balanceMap = /* @__PURE__ */ new Map();
|
|
4098
|
+
const overviewMap = /* @__PURE__ */ new Map();
|
|
4099
|
+
if (chunks.length > 0) {
|
|
4100
|
+
process.stderr.write(`\u23F3 \u62C9\u53D6\u4F59\u989D\u4E0E\u6D88\u8017\u6570\u636E\uFF08${validIds.length} \u4E2A\u8D26\u6237\uFF0C\u5206 ${chunks.length} \u6279\uFF09...
|
|
4101
|
+
`);
|
|
4102
|
+
const [bMaps, oMaps] = await Promise.all([
|
|
4103
|
+
Promise.all(chunks.map((ids) => fetchBalanceMap(media, ids, config, opts.startDate, opts.endDate, opts.verbose))),
|
|
4104
|
+
Promise.all(chunks.map((ids) => fetchOverviewMap(media, ids, config, opts.startDate, opts.endDate, opts.verbose)))
|
|
4105
|
+
]);
|
|
4106
|
+
for (const m of bMaps) for (const [k, v] of m) balanceMap.set(k, v);
|
|
4107
|
+
for (const m of oMaps) for (const [k, v] of m) overviewMap.set(k, v);
|
|
4108
|
+
}
|
|
4109
|
+
const rows = [];
|
|
4110
|
+
for (const item of accountsList) {
|
|
4111
|
+
const ma = item.ma;
|
|
4112
|
+
const id = ma?.mediaCustomerId ? String(ma.mediaCustomerId) : "";
|
|
4113
|
+
if (!id) continue;
|
|
4114
|
+
const bal = balanceMap.get(id);
|
|
4115
|
+
const ov = overviewMap.get(id);
|
|
4116
|
+
const spend = ov?.spend != null ? Number(ov.spend) : null;
|
|
4117
|
+
if (spend != null && spend <= minSpend && minSpend > 0) continue;
|
|
4118
|
+
const impressions = ov?.impressions != null ? Number(ov.impressions) : null;
|
|
4119
|
+
const clicks = ov?.clicks != null ? Number(ov.clicks) : null;
|
|
4120
|
+
const conversions = ov?.conversions != null ? Number(ov.conversions) : null;
|
|
4121
|
+
const ctr = impressions && impressions > 0 && clicks != null ? clicks / impressions : null;
|
|
4122
|
+
const cpc = clicks && clicks > 0 && spend != null ? spend / clicks : null;
|
|
4123
|
+
const cpa = conversions && conversions > 0 && spend != null ? spend / conversions : null;
|
|
4124
|
+
rows.push({
|
|
4125
|
+
mediaCustomerId: id,
|
|
4126
|
+
name: ma.mediaCustomerName ?? null,
|
|
4127
|
+
advertiserName: item.mag?.advertiserName ?? null,
|
|
4128
|
+
currencyCode: bal?.currencyCode ?? ma.currencyCode ?? null,
|
|
4129
|
+
balance: round2(bal?.remainingAccountBudget ?? null),
|
|
4130
|
+
spend: round2(spend),
|
|
4131
|
+
impressions: impressions != null ? Math.round(impressions) : null,
|
|
4132
|
+
clicks: clicks != null ? Math.round(clicks) : null,
|
|
4133
|
+
conversions: conversions != null ? Math.round(conversions) : null,
|
|
4134
|
+
ctr: ctr != null ? +(ctr * 100).toFixed(2) : null,
|
|
4135
|
+
// 百分比
|
|
4136
|
+
cpc: round2(cpc),
|
|
4137
|
+
cpa: round2(cpa),
|
|
4138
|
+
invalidOAuthToken: !!ma.invalidOAuthToken
|
|
4139
|
+
});
|
|
4140
|
+
}
|
|
4141
|
+
rows.sort((a, b) => (b.spend ?? 0) - (a.spend ?? 0));
|
|
4142
|
+
const totals = rows.reduce(
|
|
4143
|
+
(acc, r) => {
|
|
4144
|
+
acc.spend += r.spend ?? 0;
|
|
4145
|
+
acc.clicks += r.clicks ?? 0;
|
|
4146
|
+
acc.impressions += r.impressions ?? 0;
|
|
4147
|
+
acc.conversions += r.conversions ?? 0;
|
|
4148
|
+
return acc;
|
|
4149
|
+
},
|
|
4150
|
+
{ spend: 0, clicks: 0, impressions: 0, conversions: 0 }
|
|
4151
|
+
);
|
|
4152
|
+
const meta = {
|
|
4153
|
+
media,
|
|
4154
|
+
window: {
|
|
4155
|
+
startDate: opts.startDate ?? null,
|
|
4156
|
+
endDate: opts.endDate ?? null,
|
|
4157
|
+
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"
|
|
4158
|
+
},
|
|
4159
|
+
scanned: accountsList.length,
|
|
4160
|
+
returned: rows.length,
|
|
4161
|
+
totals: {
|
|
4162
|
+
spend: +totals.spend.toFixed(2),
|
|
4163
|
+
clicks: totals.clicks,
|
|
4164
|
+
impressions: totals.impressions,
|
|
4165
|
+
conversions: totals.conversions,
|
|
4166
|
+
ctr: totals.impressions > 0 ? +(totals.clicks / totals.impressions * 100).toFixed(2) : null,
|
|
4167
|
+
cpc: totals.clicks > 0 ? +(totals.spend / totals.clicks).toFixed(2) : null,
|
|
4168
|
+
cpa: totals.conversions > 0 ? +(totals.spend / totals.conversions).toFixed(2) : null
|
|
4169
|
+
},
|
|
4170
|
+
currencyNote: "\u5404\u8D26\u6237\u4E3B\u5E01\u79CD\u53EF\u80FD\u4E0D\u540C\uFF0Ctotals \u76F4\u63A5\u6C42\u548C\uFF0C\u8DE8\u5E01\u79CD\u8D26\u6237\u8BF7\u6309 currencyCode \u5B57\u6BB5\u5206\u522B\u6C47\u603B",
|
|
4171
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4172
|
+
};
|
|
4173
|
+
if (opts.json) {
|
|
4174
|
+
console.log(JSON.stringify({ ok: true, data: { items: rows }, meta }, null, 2));
|
|
4175
|
+
return;
|
|
4176
|
+
}
|
|
4177
|
+
console.log(
|
|
4178
|
+
`
|
|
4179
|
+
${media} \u8D26\u6237\u6295\u653E\u753B\u50CF\uFF1A\u8FD4\u56DE ${rows.length} \u4E2A\u8D26\u6237\uFF08\u626B\u63CF ${meta.scanned}\uFF09` + (opts.startDate && opts.endDate ? `\uFF0C\u533A\u95F4\uFF1A${opts.startDate} ~ ${opts.endDate}` : "\uFF0C\u533A\u95F4\uFF1ACLI \u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u5EFA\u8BAE\u660E\u786E\u6307\u5B9A --start/--end\uFF09") + `
|
|
4180
|
+
`
|
|
4181
|
+
);
|
|
4182
|
+
if (rows.length === 0) {
|
|
4183
|
+
console.log(" \u6682\u65E0\u6570\u636E\u3002\n");
|
|
4184
|
+
return;
|
|
4185
|
+
}
|
|
4186
|
+
const cols = [
|
|
4187
|
+
{ key: "id", header: "\u8D26\u6237ID" },
|
|
4188
|
+
{ key: "company", header: "\u516C\u53F8" },
|
|
4189
|
+
{ key: "name", header: "\u8D26\u6237\u540D" },
|
|
4190
|
+
{ key: "spend", header: "\u6D88\u8017" },
|
|
4191
|
+
{ key: "impr", header: "\u5C55\u793A" },
|
|
4192
|
+
{ key: "clicks", header: "\u70B9\u51FB" },
|
|
4193
|
+
{ key: "conv", header: "\u8F6C\u5316" },
|
|
4194
|
+
{ key: "ctr", header: "\u70B9\u51FB\u7387%" },
|
|
4195
|
+
{ key: "cpc", header: "CPC" },
|
|
4196
|
+
{ key: "cpa", header: "CPA" },
|
|
4197
|
+
{ key: "balance", header: "\u4F59\u989D" }
|
|
4198
|
+
];
|
|
4199
|
+
const fmtMoney = (v, cur) => v == null ? "-" : `${cur ?? ""} ${v.toFixed(2)}`.trim();
|
|
4200
|
+
const tableRows = rows.map((r) => ({
|
|
4201
|
+
id: r.mediaCustomerId,
|
|
4202
|
+
company: r.advertiserName ?? "-",
|
|
4203
|
+
name: r.name ?? "-",
|
|
4204
|
+
spend: fmtMoney(r.spend, r.currencyCode),
|
|
4205
|
+
impr: r.impressions != null ? String(r.impressions) : "-",
|
|
4206
|
+
clicks: r.clicks != null ? String(r.clicks) : "-",
|
|
4207
|
+
conv: r.conversions != null ? String(r.conversions) : "-",
|
|
4208
|
+
ctr: r.ctr != null ? r.ctr.toFixed(2) : "-",
|
|
4209
|
+
cpc: fmtMoney(r.cpc, r.currencyCode),
|
|
4210
|
+
cpa: fmtMoney(r.cpa, r.currencyCode),
|
|
4211
|
+
balance: fmtMoney(r.balance, r.currencyCode)
|
|
4212
|
+
}));
|
|
4213
|
+
printCliTable(tableRows, cols);
|
|
4214
|
+
console.log(
|
|
4215
|
+
`
|
|
4216
|
+
\u6C47\u603B\uFF1A\u6D88\u8017 ${meta.totals.spend}\uFF0C\u70B9\u51FB ${meta.totals.clicks}\uFF0C\u5C55\u793A ${meta.totals.impressions}\uFF0C\u8F6C\u5316 ${meta.totals.conversions}
|
|
4217
|
+
\u63D0\u793A\uFF1A
|
|
4218
|
+
- \u91D1\u989D\u4F7F\u7528\u5404\u8D26\u6237\u4E3B\u5E01\u79CD\uFF1B\u8DE8\u5E01\u79CD\u8D26\u6237\u52A0 --json \u81EA\u884C\u6309 currencyCode \u805A\u5408
|
|
4219
|
+
- \u6307\u5B9A\u8D26\u6237\u5B50\u96C6\uFF1A--accounts <id1,id2,...>\uFF08\u4E0E list-accounts \u7684 mediaCustomerId \u4E00\u81F4\uFF09
|
|
4220
|
+
- \u8FC7\u6EE4\u4F4E\u6D88\u8017\uFF1A--min-spend 10\uFF08\u53EA\u4FDD\u7559\u533A\u95F4\u5185\u6D88\u8017 > 10 \u7684\u8D26\u6237\uFF09
|
|
4221
|
+
`
|
|
4222
|
+
);
|
|
4223
|
+
}
|
|
4224
|
+
|
|
3698
4225
|
// src/commands/stats.ts
|
|
3699
4226
|
var VALID_MEDIA_TYPES3 = ["Google", "TikTok", "Yandex", "MetaAd", "BingV2", "Kwai"];
|
|
3700
4227
|
function defaultDateRange2() {
|
|
@@ -6085,6 +6612,12 @@ function toGoogleDate(dateStr, offsetDays) {
|
|
|
6085
6612
|
d.setDate(d.getDate() + offsetDays);
|
|
6086
6613
|
return d.toISOString().slice(0, 10).replace(/-/g, "/");
|
|
6087
6614
|
}
|
|
6615
|
+
function toDisplayMoney(raw) {
|
|
6616
|
+
if (raw == null) return null;
|
|
6617
|
+
const n = Number(raw);
|
|
6618
|
+
if (!Number.isFinite(n)) return null;
|
|
6619
|
+
return n / 100;
|
|
6620
|
+
}
|
|
6088
6621
|
function adgroupListUrl(googleApiUrl, account, startDate, endDate) {
|
|
6089
6622
|
const params = new URLSearchParams();
|
|
6090
6623
|
params.set("startDate", toGoogleDate(startDate, -30));
|
|
@@ -6116,7 +6649,15 @@ async function runAdCampaigns(opts) {
|
|
|
6116
6649
|
`);
|
|
6117
6650
|
process.exit(1);
|
|
6118
6651
|
}
|
|
6119
|
-
const
|
|
6652
|
+
const rawItems = data.data ?? [];
|
|
6653
|
+
const items = rawItems.map((item) => {
|
|
6654
|
+
const budgetDisplay = toDisplayMoney(item.budget);
|
|
6655
|
+
return {
|
|
6656
|
+
...item,
|
|
6657
|
+
budgetDisplay,
|
|
6658
|
+
budgetUnit: "display"
|
|
6659
|
+
};
|
|
6660
|
+
});
|
|
6120
6661
|
const n = items.length;
|
|
6121
6662
|
if (opts.json) {
|
|
6122
6663
|
console.log(
|
|
@@ -6145,7 +6686,7 @@ async function runAdCampaigns(opts) {
|
|
|
6145
6686
|
{ key: "status", header: "\u72B6\u6001" },
|
|
6146
6687
|
{ key: "channelType", header: "\u7C7B\u578B" },
|
|
6147
6688
|
{ key: "bidding", header: "\u51FA\u4EF7\u7B56\u7565" },
|
|
6148
|
-
{ key: "budget", header: "\u9884\u7B97" },
|
|
6689
|
+
{ key: "budget", header: "\u65E5\u9884\u7B97" },
|
|
6149
6690
|
{ key: "clicks", header: "\u70B9\u51FB" },
|
|
6150
6691
|
{ key: "impressions", header: "\u5C55\u793A" },
|
|
6151
6692
|
{ key: "ctr", header: "\u70B9\u51FB\u7387" },
|
|
@@ -6155,12 +6696,13 @@ async function runAdCampaigns(opts) {
|
|
|
6155
6696
|
const rows = items.map((item) => {
|
|
6156
6697
|
const ctr = item.ctr != null ? (Number(item.ctr) * 100).toFixed(2) + "%" : "\u2014";
|
|
6157
6698
|
const spend = item.spend != null ? Number(item.spend).toFixed(2) : "\u2014";
|
|
6699
|
+
const budget = item.budgetDisplay != null ? item.budgetDisplay.toFixed(2) : "\u2014";
|
|
6158
6700
|
return {
|
|
6159
6701
|
name: (item.name ?? "").slice(0, nameW),
|
|
6160
6702
|
status: item.statusV2 ?? "",
|
|
6161
6703
|
channelType: item.channelTypeV2 ?? "",
|
|
6162
6704
|
bidding: String(item.biddingStrategyTypeV2 ?? ""),
|
|
6163
|
-
budget
|
|
6705
|
+
budget,
|
|
6164
6706
|
clicks: String(item.clicks ?? 0),
|
|
6165
6707
|
impressions: String(item.impressions ?? 0),
|
|
6166
6708
|
ctr,
|
|
@@ -6187,7 +6729,15 @@ async function runAdGroups(opts) {
|
|
|
6187
6729
|
`);
|
|
6188
6730
|
process.exit(1);
|
|
6189
6731
|
}
|
|
6190
|
-
const
|
|
6732
|
+
const rawItems = data.data ?? [];
|
|
6733
|
+
const items = rawItems.map((item) => {
|
|
6734
|
+
const maxCpcDisplay = toDisplayMoney(item["maxCPCAmount"]);
|
|
6735
|
+
return {
|
|
6736
|
+
...item,
|
|
6737
|
+
maxCPCAmountDisplay: maxCpcDisplay,
|
|
6738
|
+
maxCPCAmountUnit: "display"
|
|
6739
|
+
};
|
|
6740
|
+
});
|
|
6191
6741
|
const n = items.length;
|
|
6192
6742
|
if (opts.json) {
|
|
6193
6743
|
console.log(
|
|
@@ -6221,7 +6771,8 @@ async function runAdGroups(opts) {
|
|
|
6221
6771
|
const rows = items.map((item) => {
|
|
6222
6772
|
const cpc = item["averageCpc"] != null ? Number(item["averageCpc"]).toFixed(2) : "\u2014";
|
|
6223
6773
|
const spend = item["spend"] != null ? Number(item["spend"]).toFixed(2) : "\u2014";
|
|
6224
|
-
const
|
|
6774
|
+
const maxCpcNum = item["maxCPCAmountDisplay"];
|
|
6775
|
+
const maxCpc = maxCpcNum != null ? maxCpcNum.toFixed(2) : "\u2014";
|
|
6225
6776
|
return {
|
|
6226
6777
|
name: String(item["name"] ?? "").slice(0, nameW),
|
|
6227
6778
|
campaignName: String(item["campaignName"] ?? "").slice(0, campW),
|
|
@@ -10935,6 +11486,10 @@ program.command("list-accounts").description("\u67E5\u8BE2\u5E7F\u544A\u8D26\u62
|
|
|
10935
11486
|
"--plain",
|
|
10936
11487
|
"\u5DF2\u9ED8\u8BA4 ASCII \u7EBF\u6846\uFF0C\u65E0\u9700\u518D\u4F20\uFF1B\u4FDD\u7559\u517C\u5BB9\u65E7\u811A\u672C",
|
|
10937
11488
|
false
|
|
11489
|
+
).option(
|
|
11490
|
+
"--refresh-dp",
|
|
11491
|
+
"\u5F3A\u5236\u91CD\u62C9 Datapermission \u540E\u518D\u8BF7\u6C42\u5217\u8868\uFF08\u7528\u4E8E\u300C\u7B2C\u4E8C\u6B21\u62C9\u53D6\u5168\u90E8\u5931\u6548\u300D\u7C7B\u4F1A\u8BDD\u5F02\u5E38\u7684\u4E00\u952E\u6392\u67E5\uFF09",
|
|
11492
|
+
false
|
|
10938
11493
|
).option("--verbose", "\u663E\u793A\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(async (opts) => {
|
|
10939
11494
|
await runListAccounts({
|
|
10940
11495
|
token: opts.token,
|
|
@@ -10946,6 +11501,7 @@ program.command("list-accounts").description("\u67E5\u8BE2\u5E7F\u544A\u8D26\u62
|
|
|
10946
11501
|
json: opts.json,
|
|
10947
11502
|
quick: opts.quick,
|
|
10948
11503
|
unicode: opts.unicode,
|
|
11504
|
+
refreshDp: opts.refreshDp,
|
|
10949
11505
|
verbose: opts.verbose
|
|
10950
11506
|
});
|
|
10951
11507
|
});
|
|
@@ -10983,6 +11539,46 @@ program.command("balance").description("\u67E5\u8BE2\u5E7F\u544A\u8D26\u6237\u5B
|
|
|
10983
11539
|
verbose: opts.verbose
|
|
10984
11540
|
});
|
|
10985
11541
|
});
|
|
11542
|
+
program.command("balance-scan").description(
|
|
11543
|
+
"\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"
|
|
11544
|
+
).requiredOption(
|
|
11545
|
+
"-m, --media <type>",
|
|
11546
|
+
"\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Yandex | BingV2 | Kwai\uFF08MetaAd \u63A5\u53E3\u672A\u5F00\u653E\u4F59\u989D\u67E5\u8BE2\uFF09"
|
|
11547
|
+
).option("--threshold-days <n>", "\u5269\u4F59\u7EED\u822A\u5929\u6570\u9608\u503C\uFF08\u9ED8\u8BA4 7\uFF0C\u6309\u8FD1 7 \u65E5\u65E5\u5747\u6D88\u8017\u4F30\u7B97\uFF09", (v) => parseFloat(v)).option("--min-balance <n>", "\u989D\u5916\u6309\u7EDD\u5BF9\u4F59\u989D\u9608\u503C\u547D\u4E2D\uFF08\u4E3B\u5E01\u79CD\u91D1\u989D\uFF0C\u4E0E --threshold-days \u53D6\u5E76\u96C6\uFF09", (v) => parseFloat(v)).option("--min-daily-spend <n>", "\u8FD1 7 \u65E5\u65E5\u5747\u6D88\u8017 < \u6B64\u503C\u7684\u8D26\u6237\u89C6\u4E3A\u50F5\u5C38\u8D26\u6237\u4E0D\u505A\u7EED\u822A\u4F30\u7B97\uFF08\u9ED8\u8BA4 0.01\uFF09", (v) => parseFloat(v)).option("--target-days <n>", "\u5EFA\u8BAE\u5145\u503C\u6309\u6B64\u76EE\u6807\u7EED\u822A\u5929\u6570\u8BA1\u7B97\uFF08\u9ED8\u8BA4 30\uFF09", (v) => parseFloat(v)).option("--page-size <n>", "\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\uFF1B\u8D85\u51FA\u540E\u8BF7\u63D0\u9AD8 --page-size \u6216\u5206\u6B21\u626B\u63CF\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 meta\uFF08\u626B\u63CF\u6570/\u547D\u4E2D\u6570/\u9608\u503C/\u65F6\u95F4\u6233\uFF09", false).option("--refresh-dp", "\u5F3A\u5236\u91CD\u62C9 Datapermission \u540E\u518D\u626B\u63CF\uFF08\u6392\u67E5\u4F1A\u8BDD\u5F02\u5E38\uFF09", false).option("--verbose", "\u663E\u793A\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(async (opts) => {
|
|
11548
|
+
await runBalanceScan({
|
|
11549
|
+
token: opts.token,
|
|
11550
|
+
media: opts.media,
|
|
11551
|
+
thresholdDays: opts.thresholdDays,
|
|
11552
|
+
minBalance: opts.minBalance,
|
|
11553
|
+
minDailySpend: opts.minDailySpend,
|
|
11554
|
+
targetDays: opts.targetDays,
|
|
11555
|
+
pageSize: opts.pageSize,
|
|
11556
|
+
maxPages: opts.maxPages,
|
|
11557
|
+
json: opts.json,
|
|
11558
|
+
refreshDp: opts.refreshDp,
|
|
11559
|
+
verbose: opts.verbose
|
|
11560
|
+
});
|
|
11561
|
+
});
|
|
11562
|
+
program.command("accounts-digest").description(
|
|
11563
|
+
"\u591A\u8D26\u6237\u6295\u653E\u753B\u50CF\uFF1A\u4E00\u6761\u547D\u4EE4\u62FF\u9F50\u6D88\u8017/\u70B9\u51FB/\u8F6C\u5316/\u4F59\u989D/\u6D3E\u751F\u6307\u6807\uFF08CTR/CPC/CPA\uFF09\uFF0C\u66FF\u4EE3 AI \u9010\u8D26\u6237 `list-accounts` + `stats` \u7684\u7F16\u6392\u3002\u5178\u578B\u573A\u666F\uFF1A\u300C\u8FD9 10 \u4E2A\u8D26\u6237\uFF08\u6216\u67D0\u5A92\u4F53\u5168\u90E8\u8D26\u6237\uFF09\u5E2E\u6211\u6574\u7406\u4E00\u4E0B\u300D\u3002"
|
|
11564
|
+
).requiredOption(
|
|
11565
|
+
"-m, --media <type>",
|
|
11566
|
+
"\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Yandex | BingV2 | Kwai\uFF08MetaAd \u65E0\u6D88\u8017\u6C47\u603B\u63A5\u53E3\uFF09"
|
|
11567
|
+
).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("--refresh-dp", "\u5F3A\u5236\u91CD\u62C9 Datapermission \u540E\u518D\u8BF7\u6C42", false).option("--verbose", "\u663E\u793A\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(async (opts) => {
|
|
11568
|
+
await runAccountsDigest({
|
|
11569
|
+
token: opts.token,
|
|
11570
|
+
media: opts.media,
|
|
11571
|
+
accounts: opts.accounts,
|
|
11572
|
+
startDate: opts.start,
|
|
11573
|
+
endDate: opts.end,
|
|
11574
|
+
minSpend: opts.minSpend,
|
|
11575
|
+
pageSize: opts.pageSize,
|
|
11576
|
+
maxPages: opts.maxPages,
|
|
11577
|
+
json: opts.json,
|
|
11578
|
+
refreshDp: opts.refreshDp,
|
|
11579
|
+
verbose: opts.verbose
|
|
11580
|
+
});
|
|
11581
|
+
});
|
|
10986
11582
|
program.command("stats").description("\u67E5\u8BE2\u5E7F\u544A\u6D88\u8017\u3001\u70B9\u51FB\u3001\u8F6C\u5316\u7B49\u6295\u653E\u6570\u636E\uFF08\u9ED8\u8BA4\u8FD1 7 \u5929\uFF0C\u4E0D\u542B\u4ECA\u5929\uFF09").requiredOption(
|
|
10987
11583
|
"-m, --media <type>",
|
|
10988
11584
|
"\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Yandex | MetaAd | BingV2 | Kwai"
|
package/dist/skill/SKILL.md
CHANGED
|
@@ -18,8 +18,14 @@ allowed-tools: Bash(siluzan-tso:*) Read
|
|
|
18
18
|
|
|
19
19
|
如果 CLI 尚未安装,直接帮用户执行对应平台的安装脚本:
|
|
20
20
|
|
|
21
|
-
- **macOS / Linux / WSL:**
|
|
22
|
-
|
|
21
|
+
- **macOS / Linux / WSL:**
|
|
22
|
+
```bash
|
|
23
|
+
bash <(curl -fsSL https://unpkg.com/siluzan-tso-cli@latest/dist/skill/scripts/install.sh)
|
|
24
|
+
```
|
|
25
|
+
- **Windows PowerShell:**
|
|
26
|
+
```powershell
|
|
27
|
+
irm https://unpkg.com/siluzan-tso-cli@latest/dist/skill/scripts/install.ps1 | iex
|
|
28
|
+
```
|
|
23
29
|
|
|
24
30
|
脚本会自动完成 Node.js 检测/安装、CLI 安装、Skill 全局注册,并引导用户配置 API Key。无需选择,本脚本专为 siluzan-tso-cli 定制。
|
|
25
31
|
|
|
@@ -107,6 +113,65 @@ allowed-tools: Bash(siluzan-tso:*) Read
|
|
|
107
113
|
- **具体业务的额外规范**:开户、优化、报告、Google 广告创建等场景的详细约束,请分别在执行前阅读对应的 `references/*.md` 文档。
|
|
108
114
|
- **完成写/修改/编辑/更新等操作后需要确认数据是否正确**
|
|
109
115
|
|
|
116
|
+
### 时间范围强制反问(必须遵守)
|
|
117
|
+
|
|
118
|
+
任何涉及"投放数据 / 消耗 / 报告 / 周报 / 月报 / 优化建议"的任务,如果用户没给出**明确的开始与结束日期**,**不要自己猜**(尤其不要默认"最近 30 天 / 近 7 天 / 本月")。按如下步骤处理:
|
|
119
|
+
|
|
120
|
+
1. **先反问**一次:示例措辞 —
|
|
121
|
+
> 本次分析要覆盖哪个时间范围?例如:
|
|
122
|
+
> (A)最近完整自然周(周一到周日)
|
|
123
|
+
> (B)本月 1 号到昨天
|
|
124
|
+
> (C)自定义起止日(请告诉我 `YYYY-MM-DD` 起止)
|
|
125
|
+
2. 用户给出范围后,**在报告首行显式标注"统计区间:YYYY-MM-DD ~ YYYY-MM-DD(时区:用户本地/UTC)"**,与调用参数保持完全一致。
|
|
126
|
+
3. **只有在用户明确说"按你默认来 / 你决定"**时,才使用下方默认值白名单。
|
|
127
|
+
|
|
128
|
+
### 默认值白名单(仅在用户明确授权"你决定"时才能使用)
|
|
129
|
+
|
|
130
|
+
| 场景 | 允许的默认窗口 |
|
|
131
|
+
|------|--------|
|
|
132
|
+
| 日常投放巡检 / 余额扫描 | `now - 7d` ~ `now`(本地时间) |
|
|
133
|
+
| 周报 | 上一个完整自然周(周一 00:00 ~ 周日 23:59) |
|
|
134
|
+
| 月报 | 上一个完整自然月(1 号 ~ 月末) |
|
|
135
|
+
| Google 关键词/系列分析 | `now - 30d` ~ `now`(与 TSO Google 接口最小窗口对齐) |
|
|
136
|
+
| MetaAd 账户分析 | 不得默认,必须问(Meta 接口对窗口敏感) |
|
|
137
|
+
|
|
138
|
+
### 金额与货币单位硬约束
|
|
139
|
+
|
|
140
|
+
- **永远使用 `*Display` 字段或表格展示值**做用户可见的金额。**不要用原 `budget` / `maxCPCAmount`**,它们是批量接口的 `×100` 分单位,直接展示会 100 倍放大(典型错误:¥50 显示为 ¥5000)。
|
|
141
|
+
- JSON 响应里任何以 `Display` 结尾的数字字段即为主币种展示值,单位来自同级 `currencyCode` 字段。
|
|
142
|
+
- 当命令返回 `budgetUnit: "display"` / `maxCPCAmountUnit: "display"` 标识时,**必须**优先信任该字段。
|
|
143
|
+
- 编写报告/表格时金额保留 2 位小数,并写明货币代码(例如 `¥50.00 CNY` / `$50.00 USD`)。
|
|
144
|
+
|
|
145
|
+
### 品牌名 / 公司名来源硬约束
|
|
146
|
+
|
|
147
|
+
- 生成任何带品牌名的方案、邮件、报告时,**不得自行生成中文译名或拼音**。品牌名**必须**来自以下来源之一(按优先级):
|
|
148
|
+
1. 用户在对话里明确提供的品牌名
|
|
149
|
+
2. `list-accounts` 返回的 `mag.advertiserName`
|
|
150
|
+
3. 用户提供的网址 → 明确告诉用户"使用域名作为占位"(例如 `hy-steelpipe.com`)并在交付物里标注 `[待确认品牌名]`
|
|
151
|
+
- **严禁**"hy-steelpipe.com"这样的英文域名被输出成类似"海悦钢管"这种虚构中文品牌。
|
|
152
|
+
|
|
153
|
+
### 批量任务硬约束(≥ 5 个账户或系列)
|
|
154
|
+
|
|
155
|
+
以下情形**必须**使用批量 / 扫描命令而非循环单条调用,否则严重拖慢并易卡死:
|
|
156
|
+
|
|
157
|
+
| 任务 | 推荐命令 | 禁止做法 |
|
|
158
|
+
|------|----------|----------|
|
|
159
|
+
| 多账户余额 / 预算不足预警 | `balance-scan -m <媒体> --threshold-days 7` | 逐账户循环 `balance --accounts ...` |
|
|
160
|
+
| 多账户投放画像(消耗/点击/转化汇总) | `accounts-digest -m <媒体> [-a id1,id2] --start --end --json` | 对每个账户依次 `stats --id ...` |
|
|
161
|
+
| 多系列诊断 | `ad campaigns --json` + node 过滤 | 逐系列 `ad campaign-get` |
|
|
162
|
+
|
|
163
|
+
如果命令行尚未提供批量入口(例如 117 个 Bing 账户的"剩余天数"计算),应:
|
|
164
|
+
1. 先用 `list-accounts -m BingV2 --json` 一次性拿到全量账户(含余额/日均消耗),**不要**逐账户轮询余额。
|
|
165
|
+
2. 通过 `node -e` 在本地做 `剩余天数 = 余额 / 近 7 日日均消耗` 的计算并筛选阈值。
|
|
166
|
+
3. 只对命中的账户再做后续写入操作。
|
|
167
|
+
|
|
168
|
+
### 运行时长与用户可见进度
|
|
169
|
+
|
|
170
|
+
长任务(多账户、批量优化、报告生成)**必须**满足:
|
|
171
|
+
- 预估超过 2 分钟的任务,开始前先告诉用户"预计耗时 X 分钟,将以 X 个账户一批处理"。
|
|
172
|
+
- 每处理一批输出一行进度(例如 `已处理 20 / 117`)。
|
|
173
|
+
- 处理超过 5 分钟仍未完成时,主动检查是否有必要降级(例如从"完整分析"降为"只筛阈值")并告知用户。
|
|
174
|
+
|
|
110
175
|
### 必须一定要遵守的规范
|
|
111
176
|
|
|
112
177
|
- 主动更新(详情请读取`references/setup.md`)
|
|
@@ -123,6 +188,85 @@ allowed-tools: Bash(siluzan-tso:*) Read
|
|
|
123
188
|
- 广告发布(`ad batch publish` / `ad campaign-create`)— 涉及预算消耗
|
|
124
189
|
- **只读操作可自主执行**:查询类命令(`list-accounts`、`balance`、`stats`、`report list`、`config show` 等)可直接执行,无需额外确认
|
|
125
190
|
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Playbook:高频任务的标准动作
|
|
194
|
+
|
|
195
|
+
下面是 AI 遇到常见任务时**应优先采用**的编排方式。每个 Playbook 都已把"时间反问 / 批量 / 金额单位 / 品牌名"等硬约束考虑进去。除非用户明确要求,不要偏离。
|
|
196
|
+
|
|
197
|
+
### P1 · 单账户投放画像(典型指令:"xxx 账户帮我整理一下投放数据")
|
|
198
|
+
|
|
199
|
+
> 触发关键词:某账户数据 / 投放情况 / 整理账户 / 看下某个账户的表现
|
|
200
|
+
|
|
201
|
+
1. **反问时间范围**(参见"时间范围强制反问")。
|
|
202
|
+
2. `list-accounts -m Google -k <mediaCustomerId> --quick --json`
|
|
203
|
+
- 一次拿齐:账户基础信息、创建日期、当前状态、公司名(`mag.advertiserName` 作为品牌名)
|
|
204
|
+
3. `stats --media Google --accounts <id> --start-date <S> --end-date <D> --json`
|
|
205
|
+
- 拿该区间消耗、点击、转化等;**直接读响应中的主币种数值**,不要再 ×100。
|
|
206
|
+
4. `ad campaigns --account <id> --start-date <S> --end-date <D> --json`
|
|
207
|
+
- 拿广告系列类型(Search / PMax / Display 等)、日预算(**用 `budgetDisplay`**)、优化得分相关字段。
|
|
208
|
+
5. `stats` 结合 `accountsoverview` 字段派生"开始投放时间 / 有效投放天数 / 地区消耗分布"(如接口暂未直出,在 node 里聚合)。
|
|
209
|
+
6. 用 `report-templates/google-account-diagnosis-report.md` 模板输出,**首行标注统计区间和货币**。
|
|
210
|
+
|
|
211
|
+
### P2 · 多账户余额扫描 / 预算预警(典型指令:"117 个 Bing 账户不足 7 天的挑出来")
|
|
212
|
+
|
|
213
|
+
> 触发关键词:不足 X 天 / 余额预警 / 账户要没钱 / 哪些账户要充值
|
|
214
|
+
|
|
215
|
+
**一条命令即可完成,不要循环**:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
siluzan-tso balance-scan -m BingV2 --threshold-days 7 --json
|
|
219
|
+
# 筛绝对余额:再叠加 --min-balance 100
|
|
220
|
+
# 自定义续航目标:--target-days 60 会算出"充到够用 60 天"的建议充值额
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
输出结构(`--json`):
|
|
224
|
+
```json
|
|
225
|
+
{
|
|
226
|
+
"ok": true,
|
|
227
|
+
"data": { "items": [{ "mediaCustomerId": "...", "balance": 42.3, "dailySpend": 7.1, "remainingDays": 5.9, "recommendedTopup": 170.69, ... }] },
|
|
228
|
+
"meta": { "media": "BingV2", "scannedAccounts": 117, "hitCount": 23, "thresholds": { ... } }
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
硬规矩:
|
|
233
|
+
- **绝对不要** `for id of ids { balance --accounts id }` 循环,会把 117 个账户拖成几十分钟。
|
|
234
|
+
- 输出报告时按 `remainingDays` 升序(命令默认已排);金额展示用命令返回的 `balance`/`recommendedTopup` 数值 + `currencyCode`。
|
|
235
|
+
- 若 `--verbose` 发现大量账户进入"僵尸账户(消耗过低)"分支,告诉用户这些账户没真正投放,不纳入预警。
|
|
236
|
+
|
|
237
|
+
### P3 · 多账户投放画像汇总(典型指令:"这 10 个账户数据整理一下")
|
|
238
|
+
|
|
239
|
+
> 触发关键词:这些账户 / 给我一份 X 个账户的表 / ROAS/CPA 对比
|
|
240
|
+
|
|
241
|
+
**首选一条命令**:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
siluzan-tso accounts-digest -m Google \
|
|
245
|
+
-a 4251234567,7209009390,... \
|
|
246
|
+
--start 2026-04-01 --end 2026-04-15 \
|
|
247
|
+
--json
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- 一次返回:账户清单 + 消耗/点击/展示/转化 + CTR/CPC/CPA + 余额,含 `meta.totals` 汇总和货币提示。
|
|
251
|
+
- 不指定 `-a` 时扫描媒体全部账户。
|
|
252
|
+
- 不指定 `--start/--end` 时默认近 7 天;**SKILL 要求必须先与用户确认时间范围**,再把用户确认的区间带上。
|
|
253
|
+
|
|
254
|
+
标准步骤:
|
|
255
|
+
1. **反问时间范围**(P 级硬约束),拿到用户回复后再执行 `accounts-digest`。
|
|
256
|
+
2. 如命令已返回 `--json`,直接基于其中 `data.items` 与 `meta.totals` 生成报告;**不要**再逐账户 `stats`。
|
|
257
|
+
3. 跨币种账户:按 `item.currencyCode` 分表或在 meta.currencyNote 提示的前提下分币种小计。
|
|
258
|
+
4. 金额字段严格按"金额与货币单位硬约束"处理。
|
|
259
|
+
|
|
260
|
+
### P4 · Google 账户周期报告(典型指令:"生成 2026.1.1-2026.4.15 的报告")
|
|
261
|
+
|
|
262
|
+
1. **确认时间范围**(用户已给则直接用,否则按 P1 反问)。
|
|
263
|
+
2. 按 P1 步骤拿数据;**若区间 > 3 个月**,主动分段(季度/月)以避免接口超时。
|
|
264
|
+
3. 使用 `report-templates/google-period-report.md` 模板输出。
|
|
265
|
+
4. 首行必须有:`统计区间:2026-01-01 ~ 2026-04-15` + `货币:XXX`。
|
|
266
|
+
5. 报告必须包含:账户概览、投放趋势、Top 关键词/系列 / 地区分布 / 优化建议;不得编造未拉取到的指标(例如没拉取到的关键词就写"未提供"而不是估算)。
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
126
270
|
## 一些tips
|
|
127
271
|
|
|
128
272
|
### 账户ID示例
|
package/dist/skill/_meta.json
CHANGED
|
@@ -8,15 +8,42 @@
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
## 默认做法
|
|
11
|
-
1.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
16
|
-
6.
|
|
11
|
+
1. **先确认统计区间**:除非用户已明确给出起止日期,否则**必须先反问**时间范围(例如"本月 1 号到昨天?还是自定义 YYYY-MM-DD 起止?"),**不得擅自默认**(参见 SKILL.md 的"时间范围强制反问")。
|
|
12
|
+
2. 询问用户需要生成哪些维度的报告,或直接生成默认报告:包含以下维度:执行摘要、每日投放趋势、月度汇总、广告系列表现、设备分布、地域分布、关键词表现、优化建议
|
|
13
|
+
3. 确定报告需要如何分析请查看(`report-templates/README.md`)
|
|
14
|
+
4. 根据默认模板:`report-templates/report-template.html` 为样式基准来生成html 然后将html转为pdf交付
|
|
15
|
+
5. 输出 HTML 时:**默认**以 `report-templates/report-template.html` 为样式基准(适用于一切总结性、报告性、汇总性成稿);若场景更适合作正式件、深色、单页等,再从 `report-templates/report-template*.html` 中选或询问用户,并对照 `report-templates/README.md`
|
|
16
|
+
6. 注意最终交付的是用html生成的PDF
|
|
17
|
+
7. 使用浏览器或能够打开html/pdf的插件帮用户打开报告
|
|
18
|
+
8. **报告首行**必须标注:`统计区间:YYYY-MM-DD ~ YYYY-MM-DD(货币:XXX)`,与实际调用 `--start` / `--end` 一致。
|
|
17
19
|
|
|
18
20
|
用于按账户维度拉取 Google Ads 报表、结构类数据。
|
|
19
21
|
|
|
22
|
+
## 报告中的硬约束(必须遵守)
|
|
23
|
+
|
|
24
|
+
### 品牌名 / 公司名来源
|
|
25
|
+
|
|
26
|
+
生成带品牌名、方案、邮件、广告文案的报告时,**严禁自行生成品牌名(包括中文译名、拼音、意译)**。品牌名必须来自以下来源之一,按优先级:
|
|
27
|
+
|
|
28
|
+
1. 用户在对话里明确给出的品牌名
|
|
29
|
+
2. `list-accounts --json` 响应中的 `mag.advertiserName` 字段
|
|
30
|
+
3. 用户仅提供网址时:**使用域名本身作为占位**(例如 `hy-steelpipe.com`),并在交付物里用 `[待确认品牌名]` 标注,让用户补充
|
|
31
|
+
|
|
32
|
+
反面案例(**绝对禁止**):
|
|
33
|
+
- `https://hy-steelpipe.com/` → 自行臆造成"华悦钢管 / 海悦钢管"
|
|
34
|
+
- `list-accounts` 拿到 `advertiserName: "ABC Steel"`,报告里写成"ABC 钢铁公司"
|
|
35
|
+
|
|
36
|
+
### 金额单位
|
|
37
|
+
|
|
38
|
+
- 所有金额字段必须使用命令返回的 `*Display` 字段或已转换后的主币种数值(例如 `budgetDisplay`、主币种消耗),**不得**将 `budget` / `maxCPCAmount` 等 `×100` 分单位直接当作金额展示(否则会出现"¥50 日预算被写成 ¥5000"的错误)。
|
|
39
|
+
- 金额保留 2 位小数并带货币代码(示例:`¥50.00 CNY` / `$200.00 USD`)。
|
|
40
|
+
- 货币代码从响应 `currencyCode` 字段读取;不要混用多账户货币——必要时**分币种分表**。
|
|
41
|
+
|
|
42
|
+
### 预算建议
|
|
43
|
+
|
|
44
|
+
- 方案里给出的"日预算 / 月预算 / 预估消耗"必须基于:当前账户实际预算(`budgetDisplay`)、历史日均消耗、用户给的预算上限。
|
|
45
|
+
- 不要拍脑袋给出明显不符合账户规模的预算(例如账户日均 ¥50 的情况下建议 ¥5000/日)。若数据不足以判断,请**在报告里写明"建议区间需用户确认"**,而不是直接给一个高风险数字。
|
|
46
|
+
|
|
20
47
|
## Google 账户分析数据接口
|
|
21
48
|
|
|
22
49
|
### 指标字段对照
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
## invoice-info — 发票抬头管理
|
|
9
9
|
|
|
10
|
-
对应页面:`https://www
|
|
10
|
+
对应页面:`https://www.siluzan.com/v3/foreign_trade/settings/invoiceInformation`
|
|
11
11
|
|
|
12
12
|
发票抬头是开票申请时使用的公司/企业信息模板,支持三种类型:
|
|
13
13
|
- **PI**:形式发票(境外美金账户,英文信息)
|
|
@@ -130,10 +130,10 @@ siluzan-tso config show
|
|
|
130
130
|
**示例:**
|
|
131
131
|
|
|
132
132
|
```
|
|
133
|
-
- 现金充值(单笔):https://www
|
|
134
|
-
- 现金充值(批量):https://www
|
|
135
|
-
- 月结充值: https://www
|
|
136
|
-
- 丝路赞钱包: https://www
|
|
133
|
+
- 现金充值(单笔):https://www.siluzan.com/recharge/pay
|
|
134
|
+
- 现金充值(批量):https://www.siluzan.com/recharge/pay_batch
|
|
135
|
+
- 月结充值: https://www.siluzan.com/recharge/accountBillingQuota
|
|
136
|
+
- 丝路赞钱包: https://www.siluzan.com/recharge/siluzanWallet
|
|
137
137
|
```
|
|
138
138
|
|
|
139
139
|
---
|
|
@@ -212,8 +212,8 @@ siluzan-tso report list -m Google --json
|
|
|
212
212
|
|
|
213
213
|
# 第二步:查看 webUrl
|
|
214
214
|
siluzan-tso config show
|
|
215
|
-
# webUrl: https://www
|
|
215
|
+
# webUrl: https://www.siluzan.com
|
|
216
216
|
|
|
217
217
|
# 第三步:拼接链接(Google 日报)
|
|
218
|
-
# https://www
|
|
218
|
+
# https://www.siluzan.com/media-report/publish/rpt_abc123?culture=zh-CN
|
|
219
219
|
```
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
## 安装 CLI
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g siluzan-tso-cli
|
|
13
|
+
npm install -g siluzan-tso-cli
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
---
|
|
@@ -47,7 +47,7 @@ siluzan-tso config set --api-key <Key> # 或通过 config set 直接写入
|
|
|
47
47
|
siluzan-tso config set --token <Token> # 备用:设置 JWT Token
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
API Key 获取入口:`https://www
|
|
50
|
+
API Key 获取入口:`https://www.siluzan.com/v3/foreign_trade/settings/apiKeyManagement`
|
|
51
51
|
|
|
52
52
|
### 通过环境变量传入凭据(CI/CD 推荐)
|
|
53
53
|
|
|
@@ -80,9 +80,9 @@ siluzan-tso config show
|
|
|
80
80
|
输出示例:
|
|
81
81
|
```
|
|
82
82
|
构建环境 : production
|
|
83
|
-
apiBaseUrl : https://tso-api
|
|
84
|
-
googleApiUrl : https://googleapi
|
|
85
|
-
webUrl : https://www
|
|
83
|
+
apiBaseUrl : https://tso-api.siluzan.com
|
|
84
|
+
googleApiUrl : https://googleapi.mysiluzan.com
|
|
85
|
+
webUrl : https://www.siluzan.com
|
|
86
86
|
apiKey : abcd****1234
|
|
87
87
|
```
|
|
88
88
|
|
|
@@ -10,8 +10,8 @@ $ErrorActionPreference = 'Stop'
|
|
|
10
10
|
$PKG_NAME = 'siluzan-tso-cli'
|
|
11
11
|
$CLI_BIN = 'siluzan-tso'
|
|
12
12
|
$SKILL_LABEL = 'Siluzan TSO'
|
|
13
|
-
$INSTALL_CMD = 'npm install -g siluzan-tso-cli
|
|
14
|
-
$WEB_BASE = 'https://www
|
|
13
|
+
$INSTALL_CMD = 'npm install -g siluzan-tso-cli'
|
|
14
|
+
$WEB_BASE = 'https://www.siluzan.com'
|
|
15
15
|
|
|
16
16
|
# -- Constants ----------------------------------------------------------------
|
|
17
17
|
$NODE_MAJOR_MIN = 18
|
|
@@ -10,8 +10,8 @@ set -euo pipefail
|
|
|
10
10
|
readonly PKG_NAME="siluzan-tso-cli"
|
|
11
11
|
readonly CLI_BIN="siluzan-tso"
|
|
12
12
|
readonly SKILL_LABEL="Siluzan TSO"
|
|
13
|
-
readonly INSTALL_CMD="npm install -g siluzan-tso-cli
|
|
14
|
-
readonly WEB_BASE="https://www
|
|
13
|
+
readonly INSTALL_CMD="npm install -g siluzan-tso-cli"
|
|
14
|
+
readonly WEB_BASE="https://www.siluzan.com"
|
|
15
15
|
|
|
16
16
|
# -- Constants ----------------------------------------------------------------
|
|
17
17
|
readonly NODE_MAJOR_MIN=18
|