siluzan-tso-cli 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +66 -0
  2. package/assets/siluzan-ads/SKILL.md +123 -0
  3. package/assets/siluzan-ads/references/accounts.md +644 -0
  4. package/assets/siluzan-ads/references/aigc.md +176 -0
  5. package/assets/siluzan-ads/references/finance.md +303 -0
  6. package/assets/siluzan-ads/references/google-ads-test.md +162 -0
  7. package/assets/siluzan-ads/references/google-ads.md +918 -0
  8. package/assets/siluzan-ads/references/open-account-by-media.md +61 -0
  9. package/assets/siluzan-ads/references/open-account-google-ui.md +121 -0
  10. package/assets/siluzan-ads/references/reporting.md +511 -0
  11. package/assets/siluzan-ads/references/setup.md +79 -0
  12. package/assets/siluzan-ads/references/tips.md +236 -0
  13. package/assets/siluzan-ads/references/tso-home.md +96 -0
  14. package/assets/siluzan-ads/references/workflows.md +969 -0
  15. package/dist/commands/account-history.d.ts +13 -0
  16. package/dist/commands/account-history.js +87 -0
  17. package/dist/commands/account-manage.d.ts +253 -0
  18. package/dist/commands/account-manage.js +817 -0
  19. package/dist/commands/ad.d.ts +378 -0
  20. package/dist/commands/ad.js +1169 -0
  21. package/dist/commands/ai-creation.d.ts +54 -0
  22. package/dist/commands/ai-creation.js +208 -0
  23. package/dist/commands/aigc.d.ts +27 -0
  24. package/dist/commands/aigc.js +222 -0
  25. package/dist/commands/balance.d.ts +9 -0
  26. package/dist/commands/balance.js +68 -0
  27. package/dist/commands/clue.d.ts +16 -0
  28. package/dist/commands/clue.js +103 -0
  29. package/dist/commands/config.d.ts +18 -0
  30. package/dist/commands/config.js +135 -0
  31. package/dist/commands/forewarning.d.ts +78 -0
  32. package/dist/commands/forewarning.js +275 -0
  33. package/dist/commands/init.d.ts +10 -0
  34. package/dist/commands/init.js +141 -0
  35. package/dist/commands/invoice-info.d.ts +43 -0
  36. package/dist/commands/invoice-info.js +159 -0
  37. package/dist/commands/invoice.d.ts +55 -0
  38. package/dist/commands/invoice.js +212 -0
  39. package/dist/commands/keyword.d.ts +14 -0
  40. package/dist/commands/keyword.js +125 -0
  41. package/dist/commands/list-accounts.d.ts +11 -0
  42. package/dist/commands/list-accounts.js +219 -0
  43. package/dist/commands/login.d.ts +13 -0
  44. package/dist/commands/login.js +122 -0
  45. package/dist/commands/open-account.d.ts +291 -0
  46. package/dist/commands/open-account.js +988 -0
  47. package/dist/commands/optimize.d.ts +39 -0
  48. package/dist/commands/optimize.js +143 -0
  49. package/dist/commands/report.d.ts +55 -0
  50. package/dist/commands/report.js +274 -0
  51. package/dist/commands/stats.d.ts +13 -0
  52. package/dist/commands/stats.js +109 -0
  53. package/dist/commands/transfer.d.ts +37 -0
  54. package/dist/commands/transfer.js +124 -0
  55. package/dist/config/defaults.d.ts +6 -0
  56. package/dist/config/defaults.js +11 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +1973 -0
  59. package/dist/templates/load-templates.d.ts +5 -0
  60. package/dist/templates/load-templates.js +60 -0
  61. package/dist/types/ads.d.ts +138 -0
  62. package/dist/types/ads.js +4 -0
  63. package/dist/utils/auth.d.ts +40 -0
  64. package/dist/utils/auth.js +277 -0
  65. package/package.json +48 -0
@@ -0,0 +1,125 @@
1
+ import { loadConfig, apiFetch } from "../utils/auth.js";
2
+ function requireGoogleApi(config) {
3
+ if (!config.googleApiUrl) {
4
+ console.error("\n❌ 未找到 Google API 地址,请执行:\n"
5
+ + " siluzan-tso config set --google-api <URL>\n");
6
+ process.exit(1);
7
+ }
8
+ return config.googleApiUrl;
9
+ }
10
+ /** 从网址拓词服务获取额外关键词(与前端 POST + 轮询逻辑一致) */
11
+ async function fetchUrlKeywords(url, keywords, verbose) {
12
+ const base64Url = Buffer.from(url).toString("base64");
13
+ const initUrl = `https://websitereco.mysiluzan.com/api/websitekeywordreco/request/${encodeURIComponent(base64Url)}`;
14
+ let locationToken;
15
+ try {
16
+ const response = await fetch(initUrl, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(keywords),
20
+ redirect: "manual",
21
+ });
22
+ // 取 Location 头中的 token
23
+ const location = response.headers.get("location") ?? response.url ?? "";
24
+ const parts = location.split("/");
25
+ locationToken = parts[parts.length - 1];
26
+ if (!locationToken)
27
+ throw new Error("未获取到任务 token,请检查网址是否可访问");
28
+ }
29
+ catch (err) {
30
+ if (verbose)
31
+ console.error(` [网址拓词] 初始化失败: ${err}`);
32
+ return [];
33
+ }
34
+ if (verbose)
35
+ console.error(` [网址拓词] 任务 token: ${locationToken},开始轮询...`);
36
+ const pollUrl = `https://websitereco.mysiluzan.com/api/websitekeywordreco/request/${locationToken}`;
37
+ for (let i = 0; i < 6; i++) {
38
+ await new Promise((r) => setTimeout(r, 3000));
39
+ try {
40
+ const res = await fetch(pollUrl);
41
+ if (res.ok) {
42
+ const json = (await res.json());
43
+ if (Array.isArray(json["expandedKeyword"])) {
44
+ const extra = json["expandedKeyword"].map((kw) => ({ keyword: kw }));
45
+ if (verbose)
46
+ console.error(` [网址拓词] 获取到 ${extra.length} 个关键词`);
47
+ return extra;
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ // 轮询失败继续重试
53
+ }
54
+ }
55
+ if (verbose)
56
+ console.error(" [网址拓词] 轮询超时,跳过网址拓词结果");
57
+ return [];
58
+ }
59
+ export async function runKeywordSuggest(opts) {
60
+ const config = loadConfig(opts.token);
61
+ const googleApiUrl = requireGoogleApi(config);
62
+ if (opts.keywords.length === 0) {
63
+ console.error("\n❌ 请至少提供一个关键词(-k / --keyword)\n");
64
+ process.exit(1);
65
+ }
66
+ // 主接口:Google 关键字推荐
67
+ const apiUrl = `${googleApiUrl}/keywordidea/google`;
68
+ let items = [];
69
+ try {
70
+ const data = await apiFetch(apiUrl, config, { method: "POST", body: JSON.stringify(opts.keywords) }, opts.verbose);
71
+ items = Array.isArray(data) ? data : [];
72
+ }
73
+ catch (err) {
74
+ console.error(`\n❌ 关键字推荐失败:${err instanceof Error ? err.message : String(err)}\n`);
75
+ process.exit(1);
76
+ }
77
+ // 网址拓词(可选)
78
+ if (opts.url) {
79
+ const urlKeywords = await fetchUrlKeywords(opts.url, opts.keywords, opts.verbose);
80
+ items = [...items, ...urlKeywords];
81
+ }
82
+ // 本地过滤(与前端逻辑一致)
83
+ if (opts.include) {
84
+ const includes = opts.include.split(/[\s,,]+/).filter(Boolean).map((s) => s.toLowerCase());
85
+ items = items.filter((item) => {
86
+ const kw = (item.keyword ?? "").toLowerCase();
87
+ return includes.every((inc) => kw.includes(inc));
88
+ });
89
+ }
90
+ if (opts.exclude) {
91
+ const excludes = opts.exclude.split(/[\s,,]+/).filter(Boolean).map((s) => s.toLowerCase());
92
+ items = items.filter((item) => {
93
+ const kw = (item.keyword ?? "").toLowerCase();
94
+ return !excludes.some((exc) => kw.includes(exc));
95
+ });
96
+ }
97
+ if (opts.json) {
98
+ console.log(JSON.stringify(items, null, 2));
99
+ return;
100
+ }
101
+ console.log(`\n关键字推荐(共 ${items.length} 个)\n`);
102
+ if (items.length === 0) {
103
+ console.log(" 暂无推荐关键词。\n");
104
+ return;
105
+ }
106
+ const header = [
107
+ "关键词".padEnd(40),
108
+ "月均搜索".padEnd(12),
109
+ "平均CPC".padEnd(12),
110
+ "竞争度",
111
+ ].join(" ");
112
+ console.log(" " + header);
113
+ console.log(" " + "-".repeat(header.length));
114
+ for (const item of items) {
115
+ // averageCpc 原始值为微单位,需除以 1e6
116
+ const cpc = item.averageCpc != null ? (item.averageCpc / 1e6).toFixed(2) : "—";
117
+ console.log(" " + [
118
+ (item.keyword ?? "").padEnd(40),
119
+ String(item.montlySearch ?? "—").padEnd(12),
120
+ cpc.padEnd(12),
121
+ item.competitionV2 ?? item.competition ?? "—",
122
+ ].join(" "));
123
+ }
124
+ console.log();
125
+ }
@@ -0,0 +1,11 @@
1
+ export interface ListAccountsOptions {
2
+ token?: string;
3
+ media?: string;
4
+ status?: string;
5
+ keyword?: string;
6
+ page?: number;
7
+ pageSize?: number;
8
+ verbose?: boolean;
9
+ json?: boolean;
10
+ }
11
+ export declare function runListAccounts(opts: ListAccountsOptions): Promise<void>;
@@ -0,0 +1,219 @@
1
+ import { loadConfig, apiFetch } from "../utils/auth.js";
2
+ const VALID_MEDIA_TYPES = ["Google", "TikTok", "Yandex", "MetaAd", "BingV2", "Kwai"];
3
+ const PLATFORM_CONFIG = {
4
+ Google: {
5
+ path: "/query/media-account/",
6
+ pageParam: "pageNo",
7
+ fixedParams: { MediaType: "Google", mediaAccountState: "Approved,Linked" },
8
+ responseType: "array",
9
+ idSearchParam: "mediaCustomerId",
10
+ },
11
+ Yandex: {
12
+ path: "/query/media-account/",
13
+ pageParam: "pageNo",
14
+ fixedParams: { MediaType: "Yandex", mediaAccountState: "Approved,Linked" },
15
+ responseType: "array",
16
+ idSearchParam: "mediaCustomerId",
17
+ },
18
+ TikTok: {
19
+ path: "/query/media-account/tiktok/SearchMediaAcountByCriteria",
20
+ pageParam: "pageNum",
21
+ fixedParams: {
22
+ MediaType: "TikTok",
23
+ advStatus: "STATUS_ENABLE,STATUS_LIMIT,STATUS_DISABLE",
24
+ mediaAccountState: "Approved,Linked",
25
+ isForce: false,
26
+ },
27
+ responseType: "mas",
28
+ idSearchParam: "mediaCustomerIds",
29
+ },
30
+ MetaAd: {
31
+ path: "/query/media-account/SearchMediaAcountByCriteria",
32
+ pageParam: "pageNum",
33
+ fixedParams: {
34
+ // 前端用复数 MediaTypes + | 分隔,与其他平台不同
35
+ MediaTypes: "MetaAd|FacebookAds",
36
+ advStatus: "",
37
+ mediaAccountState: "Approved,Linked",
38
+ isForce: false,
39
+ },
40
+ responseType: "mas",
41
+ idSearchParam: "mediaCustomerIds",
42
+ },
43
+ BingV2: {
44
+ path: "/query/media-account/BingV2/SearchBingV2MediaAcountByUserId",
45
+ pageParam: "pageNum",
46
+ fixedParams: {
47
+ MediaType: "BingV2",
48
+ advStatus: "APPROVED",
49
+ mediaAccountState: "Approved,Linked",
50
+ isForce: false,
51
+ },
52
+ responseType: "mas",
53
+ idSearchParam: "mediaCustomerIds",
54
+ },
55
+ Kwai: {
56
+ path: "/query/media-account/SearchMediaAcountByCriteria",
57
+ pageParam: "pageNum",
58
+ fixedParams: {
59
+ MediaType: "Kwai",
60
+ advStatus: "",
61
+ mediaAccountState: "Approved,Linked",
62
+ isForce: false,
63
+ },
64
+ responseType: "mas",
65
+ idSearchParam: "mediaCustomerIds",
66
+ },
67
+ };
68
+ // ─── 主函数 ───────────────────────────────────────────────────────────────────
69
+ export async function runListAccounts(opts) {
70
+ const config = loadConfig(opts.token);
71
+ if (opts.media && !VALID_MEDIA_TYPES.includes(opts.media)) {
72
+ console.error(`\n❌ 不支持的媒体类型:${opts.media}\n` +
73
+ ` 可选值:${VALID_MEDIA_TYPES.join(" | ")}\n`);
74
+ process.exit(1);
75
+ }
76
+ const page = opts.page ?? 1;
77
+ const pageSize = opts.pageSize ?? 20;
78
+ // 未指定媒体类型时,使用通用接口(Google 接口兼容查全部)
79
+ const platformCfg = opts.media ? PLATFORM_CONFIG[opts.media] : null;
80
+ let items;
81
+ let total;
82
+ if (platformCfg) {
83
+ // ── 平台专属接口 ──
84
+ const params = new URLSearchParams();
85
+ // 固定参数
86
+ for (const [k, v] of Object.entries(platformCfg.fixedParams)) {
87
+ params.set(k, String(v));
88
+ }
89
+ // 翻页参数
90
+ params.set(platformCfg.pageParam, String(page));
91
+ params.set("pageSize", String(pageSize));
92
+ // 状态过滤(通用接口支持 status,专属接口依赖 fixedParams 中的 advStatus)
93
+ if (platformCfg.responseType === "array" && opts.status && opts.status !== "all") {
94
+ params.set("status", opts.status);
95
+ }
96
+ // 关键字搜索:纯数字 → ID 字段;否则 → 名称字段
97
+ if (opts.keyword) {
98
+ const isNumeric = /^\d+$/.test(opts.keyword.trim());
99
+ if (isNumeric) {
100
+ params.set(platformCfg.idSearchParam, opts.keyword.trim());
101
+ }
102
+ else {
103
+ params.set("mediaCustomerName", opts.keyword.trim());
104
+ }
105
+ }
106
+ const url = `${config.apiBaseUrl}${platformCfg.path}?${params}`;
107
+ if (platformCfg.responseType === "array") {
108
+ // Google/Yandex:直接返回数组,total 在 Header(CLI 不处理 header,显示实际条数即可)
109
+ try {
110
+ items = await apiFetch(url, config, {}, opts.verbose);
111
+ }
112
+ catch (err) {
113
+ console.error(`\n❌ 查询失败:${err instanceof Error ? err.message : String(err)}\n`);
114
+ process.exit(1);
115
+ }
116
+ }
117
+ else {
118
+ // TikTok/MetaAd/BingV2/Kwai:返回 { mas, total }
119
+ let res;
120
+ try {
121
+ res = await apiFetch(url, config, {}, opts.verbose);
122
+ }
123
+ catch (err) {
124
+ console.error(`\n❌ 查询失败:${err instanceof Error ? err.message : String(err)}\n`);
125
+ process.exit(1);
126
+ }
127
+ items = Array.isArray(res?.mas) ? res.mas : [];
128
+ total = typeof res?.total === "number" ? res.total : undefined;
129
+ }
130
+ }
131
+ else {
132
+ // ── 未指定媒体类型,使用通用接口 ──
133
+ const params = new URLSearchParams();
134
+ if (opts.keyword)
135
+ params.set("keyword", opts.keyword);
136
+ if (opts.status && opts.status !== "all")
137
+ params.set("status", opts.status);
138
+ params.set("pageNo", String(page));
139
+ params.set("pageSize", String(pageSize));
140
+ const url = `${config.apiBaseUrl}/query/media-account/?${params}`;
141
+ try {
142
+ items = await apiFetch(url, config, {}, opts.verbose);
143
+ }
144
+ catch (err) {
145
+ console.error(`\n❌ 查询失败:${err instanceof Error ? err.message : String(err)}\n`);
146
+ process.exit(1);
147
+ }
148
+ }
149
+ // ── 输出 ──
150
+ if (opts.json) {
151
+ console.log(JSON.stringify(items, null, 2));
152
+ return;
153
+ }
154
+ if (items.length === 0) {
155
+ console.log("\n暂无广告账户数据。\n");
156
+ return;
157
+ }
158
+ const totalInfo = total !== undefined ? `,共 ${total} 条` : "";
159
+ console.log(`\n广告账户列表(第 ${page} 页,本页 ${items.length} 条${totalInfo})\n`);
160
+ // 从 ma / mag 提取展示字段
161
+ const rows = items.map((item) => {
162
+ const ma = item.ma;
163
+ // 账户状态优先级:禁用 > 未开通 > OAuth失效 > 正常
164
+ let status;
165
+ if (ma.disabled) {
166
+ status = "🚫 已禁用";
167
+ }
168
+ else if (!ma.mediaCustomerId) {
169
+ status = "⏳ 未开通";
170
+ }
171
+ else if (ma.invalidOAuthToken) {
172
+ status = "⚠️ OAuth失效";
173
+ }
174
+ else {
175
+ status = "✅ 正常";
176
+ }
177
+ return {
178
+ mediaType: ma.mediaAccountType ?? "",
179
+ mediaCustomerId: ma.mediaCustomerId ?? "(未开通)",
180
+ advertiserName: item.mag?.advertiserName ?? "",
181
+ mediaCustomerName: ma.mediaCustomerName ?? "",
182
+ status,
183
+ };
184
+ });
185
+ const colWidths = {
186
+ mediaType: Math.max(8, ...rows.map((r) => r.mediaType.length)),
187
+ mediaCustomerId: Math.max(10, ...rows.map((r) => r.mediaCustomerId.length)),
188
+ advertiserName: Math.max(8, ...rows.map((r) => r.advertiserName.length)),
189
+ name: Math.max(8, ...rows.map((r) => r.mediaCustomerName.length)),
190
+ };
191
+ const header = [
192
+ "媒体类型".padEnd(colWidths.mediaType),
193
+ "账户ID".padEnd(colWidths.mediaCustomerId),
194
+ "广告主".padEnd(colWidths.advertiserName),
195
+ "账户名称".padEnd(colWidths.name),
196
+ "账户状态",
197
+ ].join(" ");
198
+ console.log(" " + header);
199
+ console.log(" " + "-".repeat(header.length));
200
+ for (const row of rows) {
201
+ console.log(" " + [
202
+ row.mediaType.padEnd(colWidths.mediaType),
203
+ row.mediaCustomerId.padEnd(colWidths.mediaCustomerId),
204
+ row.advertiserName.padEnd(colWidths.advertiserName),
205
+ row.mediaCustomerName.padEnd(colWidths.name),
206
+ row.status,
207
+ ].join(" "));
208
+ }
209
+ const hasMore = total !== undefined ? page * pageSize < total : items.length >= pageSize;
210
+ if (hasMore) {
211
+ console.log(`\n 使用 --page <n> 翻页,--page-size <n> 调整每页数量。`);
212
+ }
213
+ // 被封账户提现仅支持 Google,仅在明确查询 Google 账户时提示
214
+ if (opts.media === "Google") {
215
+ console.log(`\n ℹ️ 此列表不显示 Google 封号(Suspended)状态,如需查看可提现的被封账户请运行:`);
216
+ console.log(` siluzan-tso account withdraw-list`);
217
+ }
218
+ console.log();
219
+ }
@@ -0,0 +1,13 @@
1
+ export interface LoginOptions {
2
+ /** 直接传入 API Key,跳过交互式输入流程 */
3
+ apiKey?: string;
4
+ }
5
+ /**
6
+ * siluzan-tso login [--api-key <key>]
7
+ *
8
+ * 不带 --api-key:引导用户前往丝路赞网页创建 API Key,然后粘贴到命令行。
9
+ * 带 --api-key:直接将 API Key 保存到配置文件(跳过交互)。
10
+ *
11
+ * API Key 与 siluzan-cso 共用同一份 ~/.siluzan/config.json,配置一次即可。
12
+ */
13
+ export declare function runLogin(opts?: LoginOptions): Promise<void>;
@@ -0,0 +1,122 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import * as readline from "node:readline";
5
+ import { DEFAULT_API_BASE } from "../config/defaults.js";
6
+ const CONFIG_DIR = path.join(os.homedir(), ".siluzan");
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
8
+ /**
9
+ * 根据 TSO API 地址推导前端 Web 地址。
10
+ * tso-api.siluzan.com → www.siluzan.com
11
+ * tso-api-ci.siluzan.com → www-ci.siluzan.com
12
+ */
13
+ function deriveWebBaseUrl(tsoApiBase) {
14
+ try {
15
+ const u = new URL(tsoApiBase);
16
+ u.hostname = u.hostname.replace(/^tso-api/, "www");
17
+ return u.origin;
18
+ }
19
+ catch {
20
+ return "https://www.siluzan.com";
21
+ }
22
+ }
23
+ const WEB_BASE_URL = deriveWebBaseUrl(DEFAULT_API_BASE);
24
+ const API_KEY_MANAGEMENT_URL = `${WEB_BASE_URL}/v3/foreign_trade/settings/apiKeyManagement`;
25
+ // ─── 配置存储 ─────────────────────────────────────────────────────────────────
26
+ function readConfigFile() {
27
+ if (!fs.existsSync(CONFIG_FILE))
28
+ return {};
29
+ try {
30
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function writeConfigFile(cfg) {
37
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
38
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf8");
39
+ if (process.platform !== "win32") {
40
+ try {
41
+ fs.chmodSync(CONFIG_FILE, 0o600);
42
+ }
43
+ catch {
44
+ console.warn("⚠️ 请手动执行:chmod 600 " + CONFIG_FILE);
45
+ }
46
+ }
47
+ }
48
+ function saveApiKey(key) {
49
+ // 保留已有配置,只更新 apiKey
50
+ const cfg = { ...readConfigFile(), apiKey: key };
51
+ writeConfigFile(cfg);
52
+ }
53
+ function maskSecret(s) {
54
+ return s.length > 8 ? `${s.slice(0, 4)}****${s.slice(-4)}` : "****";
55
+ }
56
+ /**
57
+ * siluzan-tso login [--api-key <key>]
58
+ *
59
+ * 不带 --api-key:引导用户前往丝路赞网页创建 API Key,然后粘贴到命令行。
60
+ * 带 --api-key:直接将 API Key 保存到配置文件(跳过交互)。
61
+ *
62
+ * API Key 与 siluzan-cso 共用同一份 ~/.siluzan/config.json,配置一次即可。
63
+ */
64
+ export async function runLogin(opts = {}) {
65
+ // ── 带 --api-key 参数:直接保存,跳过交互 ─────────────────────────────────
66
+ if (opts.apiKey !== undefined) {
67
+ const key = opts.apiKey.trim();
68
+ if (!key) {
69
+ console.error("\n❌ API Key 不能为空。\n");
70
+ process.exit(1);
71
+ }
72
+ saveApiKey(key);
73
+ console.log(`\n✅ API Key 已保存(${maskSecret(key)})`);
74
+ console.log(` 配置文件:${CONFIG_FILE}`);
75
+ console.log("\n现在可以运行:");
76
+ console.log(" siluzan-tso list-accounts 查看广告账户列表\n");
77
+ return;
78
+ }
79
+ // ── 交互式 API Key 输入流程 ───────────────────────────────────────────────
80
+ const existing = readConfigFile();
81
+ const currentKey = existing.apiKey ?? "";
82
+ if (currentKey) {
83
+ console.log(`\n已检测到已保存的 API Key(${maskSecret(currentKey)})。`);
84
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
85
+ const answer = await new Promise((res) => rl.question("是否覆盖?(y/N) ", (a) => { rl.close(); res(a.trim()); }));
86
+ if (answer.toLowerCase() !== "y") {
87
+ console.log("\n已取消,保持原有 API Key 不变。\n");
88
+ return;
89
+ }
90
+ }
91
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
92
+ const prompt = (q) => new Promise((res) => rl.question(q, (a) => res(a.trim())));
93
+ console.log("\n════════════════════════════════════════");
94
+ console.log(" Siluzan 登录(API Key)");
95
+ console.log("════════════════════════════════════════");
96
+ console.log("\n请按以下步骤获取 API Key:");
97
+ console.log("\n 1. 在浏览器中打开以下地址(需已登录丝路赞账号):");
98
+ console.log(`\n ${API_KEY_MANAGEMENT_URL}\n`);
99
+ console.log(" 2. 点击「创建 API Key」按钮生成一个新的 Key");
100
+ console.log(" 3. 复制生成的 API Key");
101
+ console.log(" 4. 粘贴到下方并按回车\n");
102
+ let apiKey = "";
103
+ for (let i = 0; i < 3; i++) {
104
+ const input = await prompt("粘贴 API Key:");
105
+ if (input) {
106
+ apiKey = input;
107
+ break;
108
+ }
109
+ console.log("❌ API Key 不能为空,请重试");
110
+ }
111
+ rl.close();
112
+ if (!apiKey) {
113
+ console.error("\n❌ 多次输入无效,请重试。\n");
114
+ process.exit(1);
115
+ }
116
+ saveApiKey(apiKey);
117
+ console.log(`\n✅ API Key 已保存(${maskSecret(apiKey)})`);
118
+ console.log(` 配置文件:${CONFIG_FILE}`);
119
+ console.log("\n现在可以运行:");
120
+ console.log(" siluzan-tso list-accounts 查看广告账户列表");
121
+ console.log(" siluzan-tso balance -m Google 查看账户余额\n");
122
+ }