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.
- package/README.md +66 -0
- package/assets/siluzan-ads/SKILL.md +123 -0
- package/assets/siluzan-ads/references/accounts.md +644 -0
- package/assets/siluzan-ads/references/aigc.md +176 -0
- package/assets/siluzan-ads/references/finance.md +303 -0
- package/assets/siluzan-ads/references/google-ads-test.md +162 -0
- package/assets/siluzan-ads/references/google-ads.md +918 -0
- package/assets/siluzan-ads/references/open-account-by-media.md +61 -0
- package/assets/siluzan-ads/references/open-account-google-ui.md +121 -0
- package/assets/siluzan-ads/references/reporting.md +511 -0
- package/assets/siluzan-ads/references/setup.md +79 -0
- package/assets/siluzan-ads/references/tips.md +236 -0
- package/assets/siluzan-ads/references/tso-home.md +96 -0
- package/assets/siluzan-ads/references/workflows.md +969 -0
- package/dist/commands/account-history.d.ts +13 -0
- package/dist/commands/account-history.js +87 -0
- package/dist/commands/account-manage.d.ts +253 -0
- package/dist/commands/account-manage.js +817 -0
- package/dist/commands/ad.d.ts +378 -0
- package/dist/commands/ad.js +1169 -0
- package/dist/commands/ai-creation.d.ts +54 -0
- package/dist/commands/ai-creation.js +208 -0
- package/dist/commands/aigc.d.ts +27 -0
- package/dist/commands/aigc.js +222 -0
- package/dist/commands/balance.d.ts +9 -0
- package/dist/commands/balance.js +68 -0
- package/dist/commands/clue.d.ts +16 -0
- package/dist/commands/clue.js +103 -0
- package/dist/commands/config.d.ts +18 -0
- package/dist/commands/config.js +135 -0
- package/dist/commands/forewarning.d.ts +78 -0
- package/dist/commands/forewarning.js +275 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +141 -0
- package/dist/commands/invoice-info.d.ts +43 -0
- package/dist/commands/invoice-info.js +159 -0
- package/dist/commands/invoice.d.ts +55 -0
- package/dist/commands/invoice.js +212 -0
- package/dist/commands/keyword.d.ts +14 -0
- package/dist/commands/keyword.js +125 -0
- package/dist/commands/list-accounts.d.ts +11 -0
- package/dist/commands/list-accounts.js +219 -0
- package/dist/commands/login.d.ts +13 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/open-account.d.ts +291 -0
- package/dist/commands/open-account.js +988 -0
- package/dist/commands/optimize.d.ts +39 -0
- package/dist/commands/optimize.js +143 -0
- package/dist/commands/report.d.ts +55 -0
- package/dist/commands/report.js +274 -0
- package/dist/commands/stats.d.ts +13 -0
- package/dist/commands/stats.js +109 -0
- package/dist/commands/transfer.d.ts +37 -0
- package/dist/commands/transfer.js +124 -0
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1973 -0
- package/dist/templates/load-templates.d.ts +5 -0
- package/dist/templates/load-templates.js +60 -0
- package/dist/types/ads.d.ts +138 -0
- package/dist/types/ads.js +4 -0
- package/dist/utils/auth.d.ts +40 -0
- package/dist/utils/auth.js +277 -0
- package/package.json +48 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as readline from "node:readline/promises";
|
|
4
|
+
import { stdin as stdinStream, stdout as stdoutStream } from "node:process";
|
|
5
|
+
import { loadConfig, apiFetch } from "../utils/auth.js";
|
|
6
|
+
/**
|
|
7
|
+
* 查询当前账号下的广告主组列表。
|
|
8
|
+
* GET /query/profile/steward-individual
|
|
9
|
+
*
|
|
10
|
+
* 接口返回完整 profile 对象,广告主组在 profile.mediaAccountGroups 中。
|
|
11
|
+
* 每个广告主组的 key 字段即为开户时需要的 MediaAccountGroupId(magKey)。
|
|
12
|
+
*/
|
|
13
|
+
export async function runListAdvertiserGroups(opts) {
|
|
14
|
+
const config = loadConfig(opts.token);
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
data = await apiFetch(`${config.apiBaseUrl}/query/profile/steward-individual`, config, {}, opts.verbose);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error(`\n❌ 查询广告主组失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
if (opts.json) {
|
|
24
|
+
console.log(JSON.stringify(data?.profile?.mediaAccountGroups ?? [], null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const groups = data?.profile?.mediaAccountGroups ?? [];
|
|
28
|
+
if (groups.length === 0) {
|
|
29
|
+
console.log("\n 暂无广告主组,请先在 Siluzan TSO 平台创建广告主。\n");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`\n广告主组列表(共 ${groups.length} 条)\n`);
|
|
33
|
+
const header = ["magKey(开户时使用)".padEnd(38), "广告主名称".padEnd(28), "类型".padEnd(12), "状态"].join(" ");
|
|
34
|
+
console.log(" " + header);
|
|
35
|
+
console.log(" " + "-".repeat(header.length));
|
|
36
|
+
for (const g of groups) {
|
|
37
|
+
const key = (g.key ?? "").toString();
|
|
38
|
+
const name = (g.advertiserName ?? "").toString();
|
|
39
|
+
const type = (g.advertiserType ?? "").toString();
|
|
40
|
+
const status = g.disabled ? "已禁用" : "正常";
|
|
41
|
+
console.log(" " + [key.padEnd(38), name.padEnd(28), type.padEnd(12), status].join(" "));
|
|
42
|
+
}
|
|
43
|
+
console.log("\n提示:Google 开户可用 `open-account google`,按公司名称自动关联广告主组,一般无需手动传 magKey。\n");
|
|
44
|
+
}
|
|
45
|
+
/** 与网页 openAnAccount 一致:无协议时补全为 https:// */
|
|
46
|
+
function normalizePromotionLinkForGoogle(url) {
|
|
47
|
+
const t = url.trim();
|
|
48
|
+
if (/^https?:\/\//i.test(t))
|
|
49
|
+
return t;
|
|
50
|
+
return `https://${t}`;
|
|
51
|
+
}
|
|
52
|
+
function extractMagKeyFromStewardResponse(res) {
|
|
53
|
+
if (!res || typeof res !== "object")
|
|
54
|
+
return undefined;
|
|
55
|
+
const o = res;
|
|
56
|
+
const top = o.magKey;
|
|
57
|
+
if (typeof top === "string" && top.trim())
|
|
58
|
+
return top.trim();
|
|
59
|
+
const inner = o.data;
|
|
60
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
61
|
+
const m = inner.magKey;
|
|
62
|
+
if (typeof m === "string" && m.trim())
|
|
63
|
+
return m.trim();
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 通用 MAG 解析:按公司名在广告主组列表中精确匹配,存在则 Update,不存在则 Add。
|
|
69
|
+
* 与网页 checkMa() + 第一步表单提交逻辑完全一致(所有媒体共用相同模式)。
|
|
70
|
+
*
|
|
71
|
+
* @param company 公司名称(用于精确匹配)
|
|
72
|
+
* @param magPayload 写入 mediaAccountGroup 的字段(各媒体有差异)
|
|
73
|
+
* @param advertiserId 若已手动指定,直接使用(跳过自动解析)
|
|
74
|
+
*/
|
|
75
|
+
async function resolveGenericMagKey(config, company, magPayload, verbose, advertiserId) {
|
|
76
|
+
if (advertiserId?.trim())
|
|
77
|
+
return advertiserId.trim();
|
|
78
|
+
let profile;
|
|
79
|
+
try {
|
|
80
|
+
profile = await apiFetch(`${config.apiBaseUrl}/query/profile/steward-individual`, config, {}, verbose);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.error(`\n❌ 无法拉取广告主组信息:${err instanceof Error ? err.message : String(err)}\n`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const groups = profile?.profile?.mediaAccountGroups ?? [];
|
|
87
|
+
const existing = groups.find((g) => (g.advertiserName ?? "").trim() === company.trim());
|
|
88
|
+
const baseUrl = `${config.apiBaseUrl}/command/profile/steward-individual`;
|
|
89
|
+
let res;
|
|
90
|
+
if (existing?.key) {
|
|
91
|
+
console.log(`\n ℹ️ 已存在同名广告主组,正在更新资料…`);
|
|
92
|
+
res = await apiFetch(`${baseUrl}/${encodeURIComponent(String(existing.key))}`, config, {
|
|
93
|
+
method: "PUT",
|
|
94
|
+
body: JSON.stringify({ advertiserName: company, ...magPayload }),
|
|
95
|
+
headers: { "s-command-type": "UpdateMediaAccountGroup" },
|
|
96
|
+
}, verbose);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.log(`\n ℹ️ 未找到同名广告主组,正在创建…`);
|
|
100
|
+
res = await apiFetch(baseUrl, config, {
|
|
101
|
+
method: "PUT",
|
|
102
|
+
body: JSON.stringify({ mediaAccountGroup: { advertiserName: company, ...magPayload } }),
|
|
103
|
+
headers: { "s-command-type": "AddMediaAccountGroup" },
|
|
104
|
+
}, verbose);
|
|
105
|
+
}
|
|
106
|
+
const magKey = extractMagKeyFromStewardResponse(res);
|
|
107
|
+
if (!magKey) {
|
|
108
|
+
if (verbose) {
|
|
109
|
+
console.error(` ⚠️ 创建/更新广告主组响应(无法解析 magKey):${JSON.stringify(res).slice(0, 500)}`);
|
|
110
|
+
}
|
|
111
|
+
console.error("\n❌ 服务端未返回 magKey,无法继续提交开户。可加 --verbose 查看响应。\n");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
return magKey;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 解析 Google 开户所需的 MediaAccountGroupId(magKey)。
|
|
118
|
+
* - 若调用方已传 advertiserId,直接使用;
|
|
119
|
+
* - 否则 GET profile,按公司名称精确匹配已有广告主组;有则 Update,无则 Add;
|
|
120
|
+
* 与网页第一步逻辑一致(见 openAnAccount.vue)。
|
|
121
|
+
*/
|
|
122
|
+
async function resolveGoogleMediaAccountGroupId(config, opts) {
|
|
123
|
+
if (opts.advertiserId?.trim()) {
|
|
124
|
+
return opts.advertiserId.trim();
|
|
125
|
+
}
|
|
126
|
+
const company = opts.company.trim();
|
|
127
|
+
const promotionLink = normalizePromotionLinkForGoogle(opts.promotionLink);
|
|
128
|
+
const level1 = (opts.industryLevel1 ?? "").trim();
|
|
129
|
+
const level2 = (opts.industryLevel2 ?? "").trim();
|
|
130
|
+
let profile;
|
|
131
|
+
try {
|
|
132
|
+
profile = await apiFetch(`${config.apiBaseUrl}/query/profile/steward-individual`, config, {}, opts.verbose);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error(`\n❌ 无法拉取广告主组信息:${err instanceof Error ? err.message : String(err)}\n`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const groups = profile?.profile?.mediaAccountGroups ?? [];
|
|
139
|
+
const existing = groups.find((g) => (g.advertiserName ?? "").trim() === company);
|
|
140
|
+
const ex = existing;
|
|
141
|
+
const storageRaw = ex?.StorageProvider ?? ex?.storageProvider;
|
|
142
|
+
const storageFromGroup = typeof storageRaw === "string" ? storageRaw : undefined;
|
|
143
|
+
const mediaAccountGroupPayload = {
|
|
144
|
+
advertiserName: company,
|
|
145
|
+
StorageProvider: storageFromGroup ?? "StorageAccount",
|
|
146
|
+
industry: { level1: level1, level2: level2 },
|
|
147
|
+
promotionLink,
|
|
148
|
+
promotionType: opts.promotionType,
|
|
149
|
+
};
|
|
150
|
+
let res;
|
|
151
|
+
const baseUrl = `${config.apiBaseUrl}/command/profile/steward-individual`;
|
|
152
|
+
if (existing?.key) {
|
|
153
|
+
if (!opts.verbose) {
|
|
154
|
+
console.log(`\n ℹ️ 已存在同名广告主组,正在更新资料…`);
|
|
155
|
+
}
|
|
156
|
+
res = await apiFetch(`${baseUrl}/${encodeURIComponent(String(existing.key))}`, config, {
|
|
157
|
+
method: "PUT",
|
|
158
|
+
body: JSON.stringify(mediaAccountGroupPayload),
|
|
159
|
+
headers: { "s-command-type": "UpdateMediaAccountGroup" },
|
|
160
|
+
}, opts.verbose);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (!opts.verbose) {
|
|
164
|
+
console.log(`\n ℹ️ 未找到同名广告主组,正在创建…`);
|
|
165
|
+
}
|
|
166
|
+
res = await apiFetch(baseUrl, config, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
body: JSON.stringify({ mediaAccountGroup: mediaAccountGroupPayload }),
|
|
169
|
+
headers: { "s-command-type": "AddMediaAccountGroup" },
|
|
170
|
+
}, opts.verbose);
|
|
171
|
+
}
|
|
172
|
+
const magKey = extractMagKeyFromStewardResponse(res);
|
|
173
|
+
if (!magKey) {
|
|
174
|
+
if (opts.verbose) {
|
|
175
|
+
console.error(` ⚠️ 创建/更新广告主组响应(无法解析 magKey):${JSON.stringify(res).slice(0, 500)}`);
|
|
176
|
+
}
|
|
177
|
+
console.error("\n❌ 服务端未返回 magKey,无法继续提交开户。可加 --verbose 查看响应。\n");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
return magKey;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 提交 Yandex 开户申请(无图片,全部为文字参数)。
|
|
184
|
+
* 流程:按公司名自动创建/匹配广告主组(与网页一致)→ POST /command/media-account/AddAgencyClient
|
|
185
|
+
*/
|
|
186
|
+
export async function runOpenAccountYandex(opts) {
|
|
187
|
+
const config = loadConfig(opts.token);
|
|
188
|
+
const magKey = await resolveGenericMagKey(config, opts.company,
|
|
189
|
+
// Yandex MAG payload 只需要 advertiserName,其余字段不传
|
|
190
|
+
{}, opts.verbose, opts.advertiserId);
|
|
191
|
+
const body = {
|
|
192
|
+
MediaAccountGroupId: magKey,
|
|
193
|
+
AgencyClientInfo: {
|
|
194
|
+
Login: opts.login,
|
|
195
|
+
FirstName: opts.firstName,
|
|
196
|
+
LastName: opts.lastName,
|
|
197
|
+
Currency: "USD",
|
|
198
|
+
Notification: {
|
|
199
|
+
Email: opts.email,
|
|
200
|
+
},
|
|
201
|
+
TinInfo: {
|
|
202
|
+
TinType: "FOREIGN_LEGAL",
|
|
203
|
+
Tin: opts.tin,
|
|
204
|
+
},
|
|
205
|
+
AccountInfo: {
|
|
206
|
+
Phone: opts.phone,
|
|
207
|
+
TaxpayerNumber: opts.taxpayerNumber ?? "",
|
|
208
|
+
WebUrl: opts.webUrl ?? "",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
const res = await apiFetch(`${config.apiBaseUrl}/command/media-account/AddAgencyClient`, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
|
|
214
|
+
console.log("\n✅ Yandex 开户申请已提交成功,请在「开户记录」页面查看审核状态。\n");
|
|
215
|
+
if (opts.verbose && res != null) {
|
|
216
|
+
console.log("响应:", JSON.stringify(res, null, 2));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.error(`\n❌ 提交失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 上传文件到附件接口,返回 { id, fileName }。
|
|
226
|
+
* POST /command/attachment(multipart/form-data)
|
|
227
|
+
*
|
|
228
|
+
* 不经过 apiFetch,直接调用 fetch,因为需要 FormData(自动处理 Content-Type + boundary)。
|
|
229
|
+
*/
|
|
230
|
+
async function uploadAttachment(filePath, apiBaseUrl, config, verbose) {
|
|
231
|
+
const fileName = path.basename(filePath);
|
|
232
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
233
|
+
const mimeType = guessContentType(fileName);
|
|
234
|
+
const form = new FormData();
|
|
235
|
+
form.append("file", new Blob([fileBuffer], { type: mimeType }), fileName);
|
|
236
|
+
const uploadUrl = `${apiBaseUrl}/command/attachment`;
|
|
237
|
+
const res = await fetch(uploadUrl, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: {
|
|
240
|
+
Authorization: `Bearer ${config.authToken}`,
|
|
241
|
+
Datapermission: config.dataPermission ?? "",
|
|
242
|
+
// 注意:不手动设置 Content-Type,让 fetch 自动添加含 boundary 的值
|
|
243
|
+
},
|
|
244
|
+
body: form,
|
|
245
|
+
});
|
|
246
|
+
const text = await res.text();
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
const detail = verbose ? `:${text.slice(0, 300)}` : "";
|
|
249
|
+
throw new Error(`上传文件 HTTP ${res.status}${detail}`);
|
|
250
|
+
}
|
|
251
|
+
const data = JSON.parse(text);
|
|
252
|
+
if (!data.id) {
|
|
253
|
+
throw new Error("上传文件失败:响应中未包含文件 ID");
|
|
254
|
+
}
|
|
255
|
+
return { id: data.id, fileName };
|
|
256
|
+
}
|
|
257
|
+
function guessContentType(fileName) {
|
|
258
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
259
|
+
const map = {
|
|
260
|
+
".jpg": "image/jpeg",
|
|
261
|
+
".jpeg": "image/jpeg",
|
|
262
|
+
".png": "image/png",
|
|
263
|
+
".gif": "image/gif",
|
|
264
|
+
".pdf": "application/pdf",
|
|
265
|
+
".bmp": "image/bmp",
|
|
266
|
+
};
|
|
267
|
+
return map[ext] ?? "application/octet-stream";
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 提交 Bing/BingV2 开户申请。
|
|
271
|
+
* 流程:先上传营业执照 → 自动创建/匹配广告主组 → 提交开户请求。
|
|
272
|
+
* 与网页 bingOpenAnAccount.vue 行为一致,用户只需填公司名,无需关心 magKey。
|
|
273
|
+
* POST /command/media-account/AddBingV2Account
|
|
274
|
+
*/
|
|
275
|
+
export async function runOpenAccountBing(opts) {
|
|
276
|
+
const config = loadConfig(opts.token);
|
|
277
|
+
if (!fs.existsSync(opts.licenseFile)) {
|
|
278
|
+
console.error(`\n❌ 营业执照文件不存在:${opts.licenseFile}\n`);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
console.log(" 正在上传营业执照...");
|
|
282
|
+
let fileId;
|
|
283
|
+
let fileName;
|
|
284
|
+
try {
|
|
285
|
+
const uploaded = await uploadAttachment(opts.licenseFile, config.apiBaseUrl, config, opts.verbose);
|
|
286
|
+
fileId = uploaded.id;
|
|
287
|
+
fileName = uploaded.fileName;
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
console.error(`\n❌ 营业执照上传失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
console.log(` 营业执照上传成功,FileId: ${fileId}`);
|
|
294
|
+
// Bing MAG payload 包含 advertiserName + 执照图片信息
|
|
295
|
+
const magKey = await resolveGenericMagKey(config, opts.advertiserName, { ImageId: fileId, StorageProvider: "StorageAccount" }, opts.verbose, opts.advertiserId);
|
|
296
|
+
const body = {
|
|
297
|
+
MediaAccountGroupId: magKey,
|
|
298
|
+
AccountCount: opts.accountCount ?? 1,
|
|
299
|
+
BingCustomerInfo: {
|
|
300
|
+
name: opts.advertiserName,
|
|
301
|
+
province: opts.province,
|
|
302
|
+
tradeId: opts.tradeId,
|
|
303
|
+
nameShort: opts.nameShort,
|
|
304
|
+
postcode: opts.postcode ?? "",
|
|
305
|
+
city: opts.city,
|
|
306
|
+
address: opts.address,
|
|
307
|
+
promotionLink: opts.promotionLink,
|
|
308
|
+
directAgent: "",
|
|
309
|
+
FileId: fileId,
|
|
310
|
+
FileName: fileName,
|
|
311
|
+
},
|
|
312
|
+
BingV2AccountInfo: {
|
|
313
|
+
nameRemarkList: [],
|
|
314
|
+
promotionLink: opts.promotionLink,
|
|
315
|
+
address: opts.address,
|
|
316
|
+
city: opts.city,
|
|
317
|
+
companyName: opts.advertiserName,
|
|
318
|
+
postcode: opts.postcode ?? "",
|
|
319
|
+
province: opts.province,
|
|
320
|
+
tradeId: opts.tradeId,
|
|
321
|
+
nameShort: opts.nameShort,
|
|
322
|
+
FileId: fileId,
|
|
323
|
+
FileName: fileName,
|
|
324
|
+
AdvertiserCid: opts.advertiserCid ?? "",
|
|
325
|
+
AdvertiserName: opts.advertiserName2 ?? "",
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
await apiFetch(`${config.apiBaseUrl}/command/media-account/AddBingV2Account`, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
|
|
330
|
+
console.log("\n✅ Bing 开户申请已提交成功,请在「开户记录」页面查看审核状态。\n");
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
console.error(`\n❌ 提交失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* 将图片 base64 上传到 Kwai,获取 blobstoreKey。
|
|
339
|
+
* POST /KwaiAccount/Management/Upload
|
|
340
|
+
* Body: { ImageType: "jpeg", base64Image: "<base64>" }
|
|
341
|
+
*/
|
|
342
|
+
async function uploadToKwai(filePath, apiBaseUrl, config, verbose) {
|
|
343
|
+
const ext = path.extname(filePath).toLowerCase().replace(".", "").replace("jpg", "jpeg");
|
|
344
|
+
const imageType = ext || "jpeg";
|
|
345
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
346
|
+
const base64Image = fileBuffer.toString("base64");
|
|
347
|
+
const res = await apiFetch(`${apiBaseUrl}/KwaiAccount/Management/Upload`, config, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
body: JSON.stringify({ ImageType: imageType, base64Image }),
|
|
350
|
+
}, verbose);
|
|
351
|
+
const blobstoreKey = res?.data?.fileBlobKey;
|
|
352
|
+
if (!blobstoreKey) {
|
|
353
|
+
throw new Error("Kwai 图片上传失败:响应中未包含 fileBlobKey");
|
|
354
|
+
}
|
|
355
|
+
return blobstoreKey;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 提交 Kwai 开户申请。
|
|
359
|
+
* 流程:先上传营业执照图片至 Kwai → 自动创建/匹配广告主组 → 提交开户请求。
|
|
360
|
+
* 与网页 KwaiOpenAnAccount.vue 行为一致,用户只需填公司名,无需关心 magKey。
|
|
361
|
+
* POST /command/media-account/AddKwaiAccount
|
|
362
|
+
*/
|
|
363
|
+
export async function runOpenAccountKwai(opts) {
|
|
364
|
+
const config = loadConfig(opts.token);
|
|
365
|
+
if (!fs.existsSync(opts.licenseFile)) {
|
|
366
|
+
console.error(`\n❌ 营业执照文件不存在:${opts.licenseFile}\n`);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
console.log(" 正在上传营业执照到 Kwai...");
|
|
370
|
+
let blobstoreKey;
|
|
371
|
+
try {
|
|
372
|
+
blobstoreKey = await uploadToKwai(opts.licenseFile, config.apiBaseUrl, config, opts.verbose);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.error(`\n❌ 营业执照上传失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
console.log(` 上传成功,blobstoreKey: ${blobstoreKey}`);
|
|
379
|
+
// Kwai MAG payload 包含 advertiserName + 执照图片信息
|
|
380
|
+
// blobstoreKey 对应 Kwai 上传的图片 ID(与网页 ImageId 概念一致)
|
|
381
|
+
const magKey = await resolveGenericMagKey(config, opts.companyName, { ImageId: blobstoreKey, StorageProvider: "StorageAccount" }, opts.verbose, opts.advertiserId);
|
|
382
|
+
const body = {
|
|
383
|
+
MediaAccountGroupId: magKey,
|
|
384
|
+
KwaiAccountInfo: {
|
|
385
|
+
licenceId: opts.licenceId,
|
|
386
|
+
licenceRegisterCountryCode: opts.licenceCountry,
|
|
387
|
+
licenceRegisterLocation: opts.licenceLocation,
|
|
388
|
+
businessScope: opts.businessScope,
|
|
389
|
+
product: opts.product,
|
|
390
|
+
advertisementType: opts.adType,
|
|
391
|
+
productUrl: opts.productUrl,
|
|
392
|
+
currencyCode: "USD",
|
|
393
|
+
licenceIdType: opts.licenceIdType,
|
|
394
|
+
accountName: opts.accountName,
|
|
395
|
+
legalEntityName: opts.companyName,
|
|
396
|
+
newIndustryId1: opts.industryId1,
|
|
397
|
+
newIndustryId2: opts.industryId2,
|
|
398
|
+
mainCertPhotos: [
|
|
399
|
+
{
|
|
400
|
+
blobstoreKey,
|
|
401
|
+
expireType: opts.expireType,
|
|
402
|
+
expireAt: opts.expireType === 2 ? 0 : (opts.expireAt ?? 0),
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
targetCountryInfos: [{ targetCountryCode: opts.targetCountry }],
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
try {
|
|
409
|
+
await apiFetch(`${config.apiBaseUrl}/command/media-account/AddKwaiAccount`, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
|
|
410
|
+
console.log("\n✅ Kwai 开户申请已提交成功,请在「开户记录」页面查看审核状态。\n");
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
console.error(`\n❌ 提交失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* GET /query/media-account/Google/TimeZoneInfo/ReadFile
|
|
419
|
+
* 与网页「第二步」广告账户表格里时区下拉同一数据源。
|
|
420
|
+
*/
|
|
421
|
+
export async function fetchGoogleTimezoneRows(config, verbose) {
|
|
422
|
+
const raw = await apiFetch(`${config.apiBaseUrl}/query/media-account/Google/TimeZoneInfo/ReadFile`, config, {}, verbose);
|
|
423
|
+
if (Array.isArray(raw))
|
|
424
|
+
return raw;
|
|
425
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.data)) {
|
|
426
|
+
return raw.data;
|
|
427
|
+
}
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
export async function runOpenAccountGoogleTimezones(opts) {
|
|
431
|
+
const config = loadConfig(opts.token);
|
|
432
|
+
let rows;
|
|
433
|
+
try {
|
|
434
|
+
rows = await fetchGoogleTimezoneRows(config, opts.verbose);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
console.error(`\n❌ 拉取时区列表失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
const kw = (opts.keyword ?? "").trim().toLowerCase();
|
|
441
|
+
if (kw) {
|
|
442
|
+
rows = rows.filter((r) => {
|
|
443
|
+
const code = String(r.Code ?? "").toLowerCase();
|
|
444
|
+
const name = String(r.Name ?? "").toLowerCase();
|
|
445
|
+
const time = String(r.Time ?? "").toLowerCase();
|
|
446
|
+
const label = `(${r.Time ?? ""})${r.Name ?? ""}`.toLowerCase();
|
|
447
|
+
return code.includes(kw) || name.includes(kw) || time.includes(kw) || label.includes(kw);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (opts.json) {
|
|
451
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
console.log(`\nGoogle 开户可选时区(与网页 /openAnAccount 下拉一致,共 ${rows.length} 条)\n`);
|
|
455
|
+
if (rows.length === 0) {
|
|
456
|
+
console.log(" 无数据。可尝试去掉 --keyword 或加 --verbose。\n");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const codeW = Math.min(36, Math.max(10, ...rows.map((r) => String(r.Code ?? "").length)));
|
|
460
|
+
const labelW = Math.min(56, Math.max(12, ...rows.map((r) => `(${r.Time ?? ""})${r.Name ?? ""}`.length)));
|
|
461
|
+
const head = ["#".padStart(4), "Code".padEnd(codeW), "展示(与网页相同)".padEnd(labelW)].join(" ");
|
|
462
|
+
console.log(" " + head);
|
|
463
|
+
console.log(" " + "-".repeat(head.length));
|
|
464
|
+
rows.forEach((r, i) => {
|
|
465
|
+
const label = `(${r.Time ?? ""})${r.Name ?? ""}`;
|
|
466
|
+
console.log(" " + [String(i + 1).padStart(4), String(r.Code ?? "").padEnd(codeW), label.slice(0, labelW).padEnd(labelW)].join(" "));
|
|
467
|
+
});
|
|
468
|
+
console.log("\n提示:选币种后网页会默认 —— CNY → Asia/Shanghai,USD → Asia/Hong_Kong;其它时区请从上表取 Code。\n");
|
|
469
|
+
}
|
|
470
|
+
/** 与网页 changeCurrency 一致 */
|
|
471
|
+
function defaultGoogleTimezoneForCurrency(currency) {
|
|
472
|
+
return currency.trim().toUpperCase() === "CNY" ? "Asia/Shanghai" : "Asia/Hong_Kong";
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* 交互式 Google 开户向导:字段顺序与网页 openAnAccount(Google)两步表单一致,
|
|
476
|
+
* 并说明顶部「五步」进度条含义(CLI 只能代劳前两步的数据采集与提交)。
|
|
477
|
+
*/
|
|
478
|
+
export async function runOpenAccountGoogleWizard(opts) {
|
|
479
|
+
if (!stdinStream.isTTY || !stdoutStream.isTTY) {
|
|
480
|
+
console.error("\n❌ 当前不是交互式终端(无 TTY)。请改用非交互命令:\n" +
|
|
481
|
+
" siluzan-tso open-account google --company \"…\" --promotion-link \"…\" ...\n" +
|
|
482
|
+
" 或先查时区:siluzan-tso open-account google-timezones\n\n");
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
const rl = readline.createInterface({ input: stdinStream, output: stdoutStream });
|
|
486
|
+
const q = async (prompt) => (await rl.question(prompt)).trim();
|
|
487
|
+
try {
|
|
488
|
+
console.log(`
|
|
489
|
+
╔══════════════════════════════════════════════════════════════════╗
|
|
490
|
+
║ Google 开户向导(对齐网页:/v3/foreign_trade/tso/openAnAccount) ║
|
|
491
|
+
╚══════════════════════════════════════════════════════════════════╝
|
|
492
|
+
|
|
493
|
+
页面顶部「五步」为整体开户生命周期(与 CLI 分工如下):
|
|
494
|
+
|
|
495
|
+
① 准备开户资料 → 你已具备公司名、网址、推广类型等信息即可
|
|
496
|
+
② 填写申请 → 【本向导收集并提交】对应网页第 1、2 步表单
|
|
497
|
+
③ 等待审核 → 网页「开户记录」或:siluzan-tso account-history -m Google
|
|
498
|
+
④ 审核通过 → 同上查看状态
|
|
499
|
+
⑤ 充值激活账户 → 须在丝路赞网页完成(CLI 无法代充值)
|
|
500
|
+
|
|
501
|
+
网页提示:美元账户最低充值约 100 USD、人民币约 700 CNY(以页面为准)。
|
|
502
|
+
|
|
503
|
+
──────────────────────── 【网页第 1 步】企业 / 推广信息 ────────────────────────
|
|
504
|
+
`);
|
|
505
|
+
const company = await q("公司名称(与营业执照一致,可匹配已有广告主组):");
|
|
506
|
+
if (!company) {
|
|
507
|
+
console.error("\n❌ 公司名不能为空。\n");
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
console.log("\n网址协议(网页左侧下拉:https:// 或 http://)");
|
|
511
|
+
const protoAns = await q("输入 1=https(默认) 2=http :");
|
|
512
|
+
const urlProto = protoAns === "2" ? "http://" : "https://";
|
|
513
|
+
const urlRest = await q(`域名或路径(不要重复协议,例如 www.example.com):`);
|
|
514
|
+
if (!urlRest) {
|
|
515
|
+
console.error("\n❌ 网址不能为空。\n");
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
const promotionLink = /^https?:\/\//i.test(urlRest) ? urlRest.trim() : `${urlProto}${urlRest.trim()}`;
|
|
519
|
+
console.log("\n推广类型(网页单选 B2B / B2C / APP)");
|
|
520
|
+
const typeAns = await q("输入 1=b2b(默认) 2=b2c 3=app :");
|
|
521
|
+
let promotionType = "b2b";
|
|
522
|
+
if (typeAns === "2")
|
|
523
|
+
promotionType = "b2c";
|
|
524
|
+
else if (typeAns === "3")
|
|
525
|
+
promotionType = "app";
|
|
526
|
+
console.log(`
|
|
527
|
+
──────────────────────── 【网页第 2 步】广告账户表格 ─────────────────────────
|
|
528
|
+
`);
|
|
529
|
+
let accountName = await q(`广告账户名称(默认与公司名相同,≤22 字符) [${company}]:`);
|
|
530
|
+
if (!accountName)
|
|
531
|
+
accountName = company;
|
|
532
|
+
console.log("\n币种(网页下拉;HKD 对代理商场景禁用,向导不提供)");
|
|
533
|
+
const curAns = await q("输入 1=USD(默认) 2=CNY :");
|
|
534
|
+
const currency = curAns === "2" ? "CNY" : "USD";
|
|
535
|
+
let timezone = defaultGoogleTimezoneForCurrency(currency);
|
|
536
|
+
console.log(`\n默认时区(与网页选币种后自动联动):${timezone}`);
|
|
537
|
+
const tzAns = await q("回车确认,或粘贴 IANA 时区 Code,或输入 list 拉取完整列表:");
|
|
538
|
+
if (tzAns.toLowerCase() === "list") {
|
|
539
|
+
const config = loadConfig(opts.token);
|
|
540
|
+
const rows = await fetchGoogleTimezoneRows(config, opts.verbose);
|
|
541
|
+
const head = ["#".padStart(4), "Code".padEnd(34), "展示"].join(" ");
|
|
542
|
+
console.log("\n " + head + "\n " + "-".repeat(Math.min(100, head.length + 20)));
|
|
543
|
+
rows.slice(0, 60).forEach((r, i) => {
|
|
544
|
+
const label = `(${r.Time ?? ""})${r.Name ?? ""}`.slice(0, 52);
|
|
545
|
+
console.log(" " + [String(i + 1).padStart(4), String(r.Code ?? "").padEnd(34), label].join(" "));
|
|
546
|
+
});
|
|
547
|
+
if (rows.length > 60)
|
|
548
|
+
console.log(` … 共 ${rows.length} 条,也可用:siluzan-tso open-account google-timezones --keyword Tokyo\n`);
|
|
549
|
+
const pick = await q("\n输入序号(1-60)或直接粘贴 Code:");
|
|
550
|
+
const n = parseInt(pick, 10);
|
|
551
|
+
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(60, rows.length)) {
|
|
552
|
+
timezone = String(rows[n - 1]?.Code ?? timezone);
|
|
553
|
+
}
|
|
554
|
+
else if (pick) {
|
|
555
|
+
timezone = pick;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else if (tzAns) {
|
|
559
|
+
timezone = tzAns;
|
|
560
|
+
}
|
|
561
|
+
const countsStr = await q("\n开户数量 1~3(网页「一个 URL 最多开几个账户」)[1]:");
|
|
562
|
+
const counts = Math.min(Math.max(parseInt(countsStr || "1", 10) || 1, 1), 3);
|
|
563
|
+
const inviteEmail = await q("\n开户邀请邮箱(须 .com 结尾,建议 Gmail):");
|
|
564
|
+
if (!inviteEmail.endsWith(".com")) {
|
|
565
|
+
console.error("\n❌ 网页要求邀请邮箱为 .com 结尾,请重新运行向导。\n");
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
console.log(`
|
|
569
|
+
──────────────────────── 确认提交 ─────────────────────────
|
|
570
|
+
公司名: ${company}
|
|
571
|
+
推广链接: ${promotionLink}
|
|
572
|
+
推广类型: ${promotionType}
|
|
573
|
+
账户名称: ${accountName}
|
|
574
|
+
币种 / 时区: ${currency} / ${timezone}
|
|
575
|
+
开户数量: ${counts}
|
|
576
|
+
邀请邮箱: ${inviteEmail}
|
|
577
|
+
──────────────────────────────────────────────────────────`);
|
|
578
|
+
const ok = await q("\n确认提交?(y/N):");
|
|
579
|
+
if (ok.toLowerCase() !== "y" && ok.toLowerCase() !== "yes") {
|
|
580
|
+
console.log("\n已取消。\n");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
await runOpenAccountGoogle({
|
|
584
|
+
token: opts.token,
|
|
585
|
+
company,
|
|
586
|
+
promotionLink,
|
|
587
|
+
promotionType,
|
|
588
|
+
accountName,
|
|
589
|
+
currency,
|
|
590
|
+
timezone,
|
|
591
|
+
counts,
|
|
592
|
+
inviteEmail,
|
|
593
|
+
verbose: opts.verbose,
|
|
594
|
+
});
|
|
595
|
+
console.log("\n后续:在网页查看顶部进度 ③④⑤,或使用:\n" +
|
|
596
|
+
" siluzan-tso account-history -m Google\n");
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
rl.close();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* 提交 Google 开户申请(无需图片,无 OAuth 跳转)。
|
|
604
|
+
* POST /command/media-account/google
|
|
605
|
+
* Header: s-command-type: AddExistingMediaAccountList
|
|
606
|
+
*
|
|
607
|
+
* 未传 advertiserId 时,会先 PUT 创建/更新广告主组(与网页一致),再提交开户。
|
|
608
|
+
* customer_info.industry 格式为 "level1-level2";不传行业时多为 "-" 或空串,与网页当前行为对齐。
|
|
609
|
+
*/
|
|
610
|
+
export async function runOpenAccountGoogle(opts) {
|
|
611
|
+
const config = loadConfig(opts.token);
|
|
612
|
+
const counts = Math.min(Math.max(opts.counts ?? 1, 1), 3);
|
|
613
|
+
const magKey = await resolveGoogleMediaAccountGroupId(config, {
|
|
614
|
+
advertiserId: opts.advertiserId,
|
|
615
|
+
company: opts.company,
|
|
616
|
+
promotionLink: opts.promotionLink,
|
|
617
|
+
promotionType: opts.promotionType,
|
|
618
|
+
industryLevel1: opts.industryLevel1,
|
|
619
|
+
industryLevel2: opts.industryLevel2,
|
|
620
|
+
verbose: opts.verbose,
|
|
621
|
+
});
|
|
622
|
+
const level1 = (opts.industryLevel1 ?? "").trim();
|
|
623
|
+
const level2 = (opts.industryLevel2 ?? "").trim();
|
|
624
|
+
const industryStr = level1 || level2 ? `${level1}-${level2}` : "";
|
|
625
|
+
const promotionLinkNorm = normalizePromotionLinkForGoogle(opts.promotionLink);
|
|
626
|
+
const body = {
|
|
627
|
+
accountInfo: Array.from({ length: counts }, () => ({
|
|
628
|
+
advertiser_info: {
|
|
629
|
+
name: opts.accountName,
|
|
630
|
+
currency: opts.currency,
|
|
631
|
+
timezone: opts.timezone,
|
|
632
|
+
accounttype: "Adwords",
|
|
633
|
+
inviteduseremail: opts.inviteEmail,
|
|
634
|
+
inviteduserrole: opts.inviteRole ?? "Standard",
|
|
635
|
+
},
|
|
636
|
+
customer_info: {
|
|
637
|
+
company: opts.company.trim(),
|
|
638
|
+
industry: industryStr,
|
|
639
|
+
},
|
|
640
|
+
qualification_info: {
|
|
641
|
+
promotion_link: promotionLinkNorm,
|
|
642
|
+
promotion_type: opts.promotionType,
|
|
643
|
+
},
|
|
644
|
+
auto_allot_mailbox: opts.autoAllotMailbox ?? false,
|
|
645
|
+
counts: 1,
|
|
646
|
+
})),
|
|
647
|
+
MediaAccountGroupId: magKey,
|
|
648
|
+
IsSaltAdd: false,
|
|
649
|
+
ManagerCustomerId: opts.managerCustomerId ?? "",
|
|
650
|
+
};
|
|
651
|
+
try {
|
|
652
|
+
await apiFetch(`${config.apiBaseUrl}/command/media-account/google`, config, {
|
|
653
|
+
method: "POST",
|
|
654
|
+
body: JSON.stringify(body),
|
|
655
|
+
headers: { "s-command-type": "AddExistingMediaAccountList" },
|
|
656
|
+
}, opts.verbose);
|
|
657
|
+
console.log(`\n✅ Google 开户申请已提交(共 ${counts} 个),邀请邮件将发送到 ${opts.inviteEmail}。\n请在「开户记录」页面查看审核状态。\n`);
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
console.error(`\n❌ 提交失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* 将营业执照图片上传到 TikTok 平台,获取 license_image_id。
|
|
666
|
+
* POST /command/media-account/tiktok/Upload
|
|
667
|
+
* Body: { MediaType, fileBase64, fileName, businessCentreType }
|
|
668
|
+
*/
|
|
669
|
+
async function uploadLicenseToTikTok(filePath, businessCentreType, config, verbose) {
|
|
670
|
+
const fileName = path.basename(filePath);
|
|
671
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
672
|
+
const base64Image = fileBuffer.toString("base64");
|
|
673
|
+
const res = await apiFetch(`${config.apiBaseUrl}/command/media-account/tiktok/Upload`, config, {
|
|
674
|
+
method: "POST",
|
|
675
|
+
body: JSON.stringify({
|
|
676
|
+
MediaType: "Tiktok",
|
|
677
|
+
fileBase64: base64Image,
|
|
678
|
+
fileName,
|
|
679
|
+
businessCentreType,
|
|
680
|
+
}),
|
|
681
|
+
}, verbose);
|
|
682
|
+
const imageId = res?.data?.image_id;
|
|
683
|
+
if (!imageId) {
|
|
684
|
+
throw new Error("TikTok 图片上传失败:响应中未包含 image_id");
|
|
685
|
+
}
|
|
686
|
+
return imageId;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* 查询该营业执照是否需要法人银联验证信息。
|
|
690
|
+
* GET /query/media-account/tiktok/... (CheckUnionpayInfo)
|
|
691
|
+
*/
|
|
692
|
+
async function checkUnionpayRequired(licenseNo, company, businessCentreType, config, verbose) {
|
|
693
|
+
try {
|
|
694
|
+
const params = new URLSearchParams({
|
|
695
|
+
license_no: licenseNo,
|
|
696
|
+
company_name: company,
|
|
697
|
+
businessCentreType,
|
|
698
|
+
});
|
|
699
|
+
// TikTok 相关接口走 tiktokApiUrl(前端从 config 里取),
|
|
700
|
+
// 但该接口也在 tso-api 下(和其他 tiktok 接口一样)
|
|
701
|
+
const res = await apiFetch(`${config.apiBaseUrl}/query/media-account/tiktok/TikTokAdvQuery/CheckUnionpayInfo?${params}`, config, {}, verbose);
|
|
702
|
+
return res?.data?.unionpay_verification_required === true;
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
// 查询失败时保守处理:不传法人信息
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* 提交 TikTok 开户申请。
|
|
711
|
+
* 流程:
|
|
712
|
+
* 1. 上传营业执照到 TikTok → 获取 license_image_id
|
|
713
|
+
* 2. 上传营业执照到 Siluzan 附件系统 → 获取 imageId(存档 + MAG 用)
|
|
714
|
+
* 3. 按公司名自动创建/匹配广告主组(与网页 openAnAccount.vue 第一步一致)
|
|
715
|
+
* 4. 查询是否需要法人银联验证信息
|
|
716
|
+
* 5. POST /command/media-account(Header: s-command-type: AddExistingMediaAccountList)
|
|
717
|
+
*/
|
|
718
|
+
export async function runOpenAccountTikTok(opts) {
|
|
719
|
+
const config = loadConfig(opts.token);
|
|
720
|
+
const bcType = opts.businessCentreType ?? "Shop";
|
|
721
|
+
if (!fs.existsSync(opts.licenseFile)) {
|
|
722
|
+
console.error(`\n❌ 营业执照文件不存在:${opts.licenseFile}\n`);
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
// 步骤 1:上传到 TikTok 平台
|
|
726
|
+
console.log(" [1/4] 正在上传营业执照到 TikTok...");
|
|
727
|
+
let licenseImageId;
|
|
728
|
+
try {
|
|
729
|
+
licenseImageId = await uploadLicenseToTikTok(opts.licenseFile, bcType, config, opts.verbose);
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
console.error(`\n❌ 上传到 TikTok 失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
console.log(` TikTok license_image_id: ${licenseImageId}`);
|
|
736
|
+
// 步骤 2:上传到 Siluzan 附件系统(存档 + 广告主组用)
|
|
737
|
+
console.log(" [2/4] 正在上传营业执照到 Siluzan 存档...");
|
|
738
|
+
let siluzanImageId = "";
|
|
739
|
+
try {
|
|
740
|
+
const uploaded = await uploadAttachment(opts.licenseFile, config.apiBaseUrl, config, opts.verbose);
|
|
741
|
+
siluzanImageId = uploaded.id;
|
|
742
|
+
console.log(` Siluzan imageId: ${siluzanImageId}`);
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
// 存档上传失败不阻断流程,给出警告即可
|
|
746
|
+
console.warn(` ⚠️ Siluzan 存档上传失败(不影响开户):${err instanceof Error ? err.message : String(err)}`);
|
|
747
|
+
}
|
|
748
|
+
// 步骤 3:按公司名自动创建/匹配广告主组
|
|
749
|
+
console.log(" [3/4] 正在关联广告主组...");
|
|
750
|
+
const magKey = await resolveGenericMagKey(config, opts.company, {
|
|
751
|
+
ImageId: siluzanImageId,
|
|
752
|
+
BusinessLicenseId: opts.licenseNo,
|
|
753
|
+
businessCentreType: bcType,
|
|
754
|
+
advertiserContactMail: "",
|
|
755
|
+
RegisteredArea: opts.registeredArea,
|
|
756
|
+
StorageProvider: "StorageAccount",
|
|
757
|
+
ManagerCustomers: [],
|
|
758
|
+
}, opts.verbose, opts.advertiserId);
|
|
759
|
+
// 步骤 4:检查是否需要法人银联验证
|
|
760
|
+
console.log(" [4/4] 正在检查法人验证要求...");
|
|
761
|
+
const needUnionpay = await checkUnionpayRequired(opts.licenseNo, opts.company, bcType, config, opts.verbose);
|
|
762
|
+
if (needUnionpay && (!opts.representativeName || !opts.representativeId)) {
|
|
763
|
+
console.error("\n❌ 该营业执照需要填写法人银联验证信息,请追加以下参数再重试:\n" +
|
|
764
|
+
" --representative-name <法人姓名>\n" +
|
|
765
|
+
" --representative-id <身份证号>\n" +
|
|
766
|
+
" --unionpay-account <银联账号>\n" +
|
|
767
|
+
" --representative-phone <手机号>\n");
|
|
768
|
+
process.exit(1);
|
|
769
|
+
}
|
|
770
|
+
const counts = Math.min(Math.max(opts.counts ?? 1, 1), 10);
|
|
771
|
+
// 构造单条账户信息
|
|
772
|
+
const singleAccountInfo = {
|
|
773
|
+
advertiser_info: {
|
|
774
|
+
name: opts.accountName,
|
|
775
|
+
currency: opts.currency,
|
|
776
|
+
timezone: opts.timezone,
|
|
777
|
+
},
|
|
778
|
+
customer_info: {
|
|
779
|
+
company: opts.company,
|
|
780
|
+
industry: opts.industryId,
|
|
781
|
+
registered_area: opts.registeredArea,
|
|
782
|
+
},
|
|
783
|
+
qualification_info: {
|
|
784
|
+
promotion_link: opts.promotionLink,
|
|
785
|
+
license_no: opts.licenseNo,
|
|
786
|
+
license_image_id: licenseImageId,
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
if (needUnionpay && opts.representativeName) {
|
|
790
|
+
singleAccountInfo.representative_info = {
|
|
791
|
+
representative_name: opts.representativeName,
|
|
792
|
+
representative_id: opts.representativeId ?? "",
|
|
793
|
+
unionpay_account: opts.unionpayAccount ?? "",
|
|
794
|
+
representative_phone_number: opts.representativePhone ?? "",
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const body = {
|
|
798
|
+
accountInfo: Array.from({ length: counts }, () => ({ ...singleAccountInfo })),
|
|
799
|
+
MediaAccountGroupId: magKey,
|
|
800
|
+
IsSaltAdd: false,
|
|
801
|
+
ManagerCustomerId: "",
|
|
802
|
+
PartnerId: opts.partnerId ?? "",
|
|
803
|
+
};
|
|
804
|
+
try {
|
|
805
|
+
await apiFetch(`${config.apiBaseUrl}/command/media-account`, config, {
|
|
806
|
+
method: "POST",
|
|
807
|
+
body: JSON.stringify(body),
|
|
808
|
+
headers: { "s-command-type": "AddExistingMediaAccountList" },
|
|
809
|
+
}, opts.verbose);
|
|
810
|
+
console.log(`\n✅ TikTok 开户申请已提交(共 ${counts} 个)。\n请在「开户记录」页面查看审核状态。\n`);
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
console.error(`\n❌ 提交失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
814
|
+
process.exit(1);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* GET /query/media-account/tiktok/TikTokTimeZoneInfo/Read
|
|
819
|
+
* 与网页 openAnAccount(TikTok)时区下拉同一数据源,结构与 Google 时区接口一致。
|
|
820
|
+
*/
|
|
821
|
+
export async function runOpenAccountTikTokTimezones(opts) {
|
|
822
|
+
const config = loadConfig(opts.token);
|
|
823
|
+
let rows;
|
|
824
|
+
try {
|
|
825
|
+
const raw = await apiFetch(`${config.apiBaseUrl}/query/media-account/tiktok/TikTokTimeZoneInfo/Read`, config, {}, opts.verbose);
|
|
826
|
+
rows = Array.isArray(raw) ? raw
|
|
827
|
+
: Array.isArray(raw.data) ? raw.data
|
|
828
|
+
: [];
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
console.error(`\n❌ 拉取 TikTok 时区列表失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
832
|
+
process.exit(1);
|
|
833
|
+
}
|
|
834
|
+
// 按 UTC offset 升序排序(与网页行为一致)
|
|
835
|
+
rows = [...rows].sort((a, b) => {
|
|
836
|
+
const ta = String(a.Time ?? "").slice(3, 6);
|
|
837
|
+
const tb = String(b.Time ?? "").slice(3, 6);
|
|
838
|
+
return ta.localeCompare(tb);
|
|
839
|
+
});
|
|
840
|
+
const kw = (opts.keyword ?? "").trim().toLowerCase();
|
|
841
|
+
if (kw) {
|
|
842
|
+
rows = rows.filter((r) => {
|
|
843
|
+
const code = String(r.Code ?? "").toLowerCase();
|
|
844
|
+
const name = String(r.Name ?? "").toLowerCase();
|
|
845
|
+
const time = String(r.Time ?? "").toLowerCase();
|
|
846
|
+
return code.includes(kw) || name.includes(kw) || time.includes(kw);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (opts.json) {
|
|
850
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
console.log(`\nTikTok 开户可选时区(共 ${rows.length} 条)\n`);
|
|
854
|
+
if (rows.length === 0) {
|
|
855
|
+
console.log(" 无数据。\n");
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const codeW = Math.min(36, Math.max(10, ...rows.map((r) => String(r.Code ?? "").length)));
|
|
859
|
+
console.log(" " + ["Code(--timezone 传此值)".padEnd(codeW + 2), "UTC 偏移".padEnd(10), "名称"].join(" "));
|
|
860
|
+
console.log(" " + "-".repeat(codeW + 40));
|
|
861
|
+
for (const r of rows) {
|
|
862
|
+
const code = String(r.Code ?? "").padEnd(codeW + 2);
|
|
863
|
+
const time = String(r.Time ?? "").padEnd(10);
|
|
864
|
+
const name = String(r.Name ?? "");
|
|
865
|
+
console.log(" " + [code, time, name].join(" "));
|
|
866
|
+
}
|
|
867
|
+
console.log();
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* GET /query/media-account/tiktok/TikTokIndustryInfo/Read
|
|
871
|
+
* 返回 TikTok 行业列表,前端以两级 Cascader 展示。
|
|
872
|
+
* ID 第 5-6 位(0-indexed 4-5)为 "01" 的是一级分类,其余与同一级 2-4 位相同的是其子项。
|
|
873
|
+
* 提交开户时只传叶子节点 ID(--industry-id)。
|
|
874
|
+
*/
|
|
875
|
+
export async function runOpenAccountTikTokIndustries(opts) {
|
|
876
|
+
const config = loadConfig(opts.token);
|
|
877
|
+
let rows;
|
|
878
|
+
try {
|
|
879
|
+
const raw = await apiFetch(`${config.apiBaseUrl}/query/media-account/tiktok/TikTokIndustryInfo/Read`, config, {}, opts.verbose);
|
|
880
|
+
rows = Array.isArray(raw) ? raw
|
|
881
|
+
: Array.isArray(raw.data) ? raw.data
|
|
882
|
+
: [];
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
console.error(`\n❌ 拉取 TikTok 行业列表失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
886
|
+
process.exit(1);
|
|
887
|
+
}
|
|
888
|
+
const kw = (opts.keyword ?? "").trim().toLowerCase();
|
|
889
|
+
if (kw) {
|
|
890
|
+
rows = rows.filter((r) => String(r.IndustryName ?? "").toLowerCase().includes(kw));
|
|
891
|
+
}
|
|
892
|
+
if (opts.json) {
|
|
893
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
// 按 ID 区分一级/二级并缩进展示
|
|
897
|
+
console.log(`\nTikTok 行业列表(共 ${rows.length} 条,--industry-id 传叶子节点 ID)\n`);
|
|
898
|
+
if (rows.length === 0) {
|
|
899
|
+
console.log(" 无数据。\n");
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
for (const r of rows) {
|
|
903
|
+
const id = String(r.IndustryId ?? "");
|
|
904
|
+
const name = String(r.IndustryName ?? "");
|
|
905
|
+
// ID 第 5-6 位为 "01" 视为一级分类
|
|
906
|
+
const isTop = id.length >= 6 && id.slice(4, 6) === "01";
|
|
907
|
+
if (isTop) {
|
|
908
|
+
console.log(` ▸ ${name} (${id})`);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
console.log(` ${name} (${id})`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
console.log();
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* GET /query/media-account/bing/BingTradeList/Read
|
|
918
|
+
* 返回 BingV2 行业列表(中文名),name 字段即为 --trade-id 的传入值。
|
|
919
|
+
*/
|
|
920
|
+
export async function runOpenAccountBingIndustries(opts) {
|
|
921
|
+
const config = loadConfig(opts.token);
|
|
922
|
+
let list;
|
|
923
|
+
try {
|
|
924
|
+
const raw = await apiFetch(`${config.apiBaseUrl}/query/media-account/bing/BingTradeList/Read`, config, {}, opts.verbose);
|
|
925
|
+
// 响应可能是 { value: [...] } 或直接是数组
|
|
926
|
+
list = Array.isArray(raw) ? raw : (raw.value ?? []);
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
console.error(`\n❌ 拉取 Bing 行业列表失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
930
|
+
process.exit(1);
|
|
931
|
+
}
|
|
932
|
+
const kw = (opts.keyword ?? "").trim().toLowerCase();
|
|
933
|
+
if (kw) {
|
|
934
|
+
list = list.filter((r) => r.name.toLowerCase().includes(kw));
|
|
935
|
+
}
|
|
936
|
+
if (opts.json) {
|
|
937
|
+
console.log(JSON.stringify(list, null, 2));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
console.log(`\nBingV2 行业列表(共 ${list.length} 条,将 name 值传给 --trade-id)\n`);
|
|
941
|
+
if (list.length === 0) {
|
|
942
|
+
console.log(" 无数据。\n");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
for (const r of list) {
|
|
946
|
+
console.log(` ${r.name}`);
|
|
947
|
+
}
|
|
948
|
+
console.log();
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* GET /query/media-account/tiktok/TikTokAreacode/Read
|
|
952
|
+
* 返回 TikTok 注册地(--registered-area)的合法值列表。
|
|
953
|
+
* Key 为提交值(如 "CN"),Value 为显示名。
|
|
954
|
+
*/
|
|
955
|
+
export async function runOpenAccountTikTokAreas(opts) {
|
|
956
|
+
const config = loadConfig(opts.token);
|
|
957
|
+
let rows;
|
|
958
|
+
try {
|
|
959
|
+
const raw = await apiFetch(`${config.apiBaseUrl}/query/media-account/tiktok/TikTokAreacode/Read`, config, {}, opts.verbose);
|
|
960
|
+
rows = Array.isArray(raw) ? raw
|
|
961
|
+
: Array.isArray(raw.data) ? raw.data
|
|
962
|
+
: [];
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
console.error(`\n❌ 拉取 TikTok 注册地列表失败:${err instanceof Error ? err.message : String(err)}\n`);
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
const kw = (opts.keyword ?? "").trim().toLowerCase();
|
|
969
|
+
if (kw) {
|
|
970
|
+
rows = rows.filter((r) => String(r.Key ?? "").toLowerCase().includes(kw) ||
|
|
971
|
+
String(r.Value ?? "").toLowerCase().includes(kw));
|
|
972
|
+
}
|
|
973
|
+
if (opts.json) {
|
|
974
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
console.log(`\nTikTok 注册地列表(共 ${rows.length} 条,--registered-area 传 Key 值)\n`);
|
|
978
|
+
if (rows.length === 0) {
|
|
979
|
+
console.log(" 无数据。\n");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
console.log(" " + "Key(--registered-area 传此值)".padEnd(10) + " " + "名称");
|
|
983
|
+
console.log(" " + "-".repeat(40));
|
|
984
|
+
for (const r of rows) {
|
|
985
|
+
console.log(" " + String(r.Key ?? "").padEnd(10) + " " + String(r.Value ?? ""));
|
|
986
|
+
}
|
|
987
|
+
console.log();
|
|
988
|
+
}
|