beervid-app-cli 0.2.3 → 0.2.5
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 +57 -1
- package/dist/cli.mjs +84 -65
- package/package.json +2 -4
- package/skills/beervid-app-cli/SKILL.md +318 -0
- package/skills/beervid-app-cli/docs/database-schema.md +231 -0
- package/skills/beervid-app-cli/docs/oauth-callback.md +282 -0
- package/skills/beervid-app-cli/docs/retry-and-idempotency.md +295 -0
- package/skills/beervid-app-cli/docs/tt-poll-task.md +239 -0
- package/skills/beervid-app-cli/docs/tts-product-cache.md +256 -0
- package/skills/beervid-app-cli/example/express/README.md +58 -0
- package/skills/beervid-app-cli/example/express/package.json +20 -0
- package/skills/beervid-app-cli/example/express/server.ts +431 -0
- package/skills/beervid-app-cli/example/express/tsconfig.json +12 -0
- package/skills/beervid-app-cli/example/nextjs/.env.example +3 -0
- package/skills/beervid-app-cli/example/nextjs/README.md +54 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/oauth/callback/route.ts +34 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/oauth/url/route.ts +30 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/products/route.ts +43 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/publish/tt/route.ts +116 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/publish/tts/route.ts +58 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/status/[shareId]/route.ts +41 -0
- package/skills/beervid-app-cli/example/nextjs/app/layout.tsx +9 -0
- package/skills/beervid-app-cli/example/nextjs/app/page.tsx +80 -0
- package/skills/beervid-app-cli/example/nextjs/lib/beervid-client.ts +107 -0
- package/skills/beervid-app-cli/example/nextjs/next.config.ts +4 -0
- package/skills/beervid-app-cli/example/nextjs/package.json +19 -0
- package/skills/beervid-app-cli/example/nextjs/tsconfig.json +23 -0
- package/skills/beervid-app-cli/example/standard/README.md +51 -0
- package/skills/beervid-app-cli/example/standard/api-client.ts +181 -0
- package/skills/beervid-app-cli/example/standard/get-oauth-url.ts +44 -0
- package/skills/beervid-app-cli/example/standard/package.json +18 -0
- package/skills/beervid-app-cli/example/standard/query-products.ts +141 -0
- package/skills/beervid-app-cli/example/standard/tsconfig.json +12 -0
- package/skills/beervid-app-cli/example/standard/tt-publish-flow.ts +194 -0
- package/skills/beervid-app-cli/example/standard/tts-publish-flow.ts +246 -0
- package/SKILL.md +0 -486
- package/agents/openai.yaml +0 -7
- /package/{references → skills/beervid-app-cli/references}/api-reference.md +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEERVID Open API 通用客户端封装
|
|
3
|
+
*
|
|
4
|
+
* 提供 openApiGet / openApiPost / openApiUpload 三个基础请求函数,
|
|
5
|
+
* 包含统一的认证、错误处理和可选的重试机制。
|
|
6
|
+
*
|
|
7
|
+
* 使用方式:
|
|
8
|
+
* import { openApiGet, openApiPost, openApiUpload } from './api-client.js'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── 环境配置 ───────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const API_KEY = process.env['BEERVID_APP_KEY'] ?? ''
|
|
14
|
+
const BASE_URL = process.env['BEERVID_APP_BASE_URL'] ?? 'https://open.beervid.ai'
|
|
15
|
+
|
|
16
|
+
if (!API_KEY) {
|
|
17
|
+
console.error('错误: 请设置环境变量 BEERVID_APP_KEY')
|
|
18
|
+
console.error(' export BEERVID_APP_KEY="your-api-key"')
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── 类型定义 ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
interface OpenApiResponse<T> {
|
|
25
|
+
code: number
|
|
26
|
+
message: string
|
|
27
|
+
data: T
|
|
28
|
+
error: boolean
|
|
29
|
+
success: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── 响应处理 ───────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
|
35
|
+
if (!res.ok && res.status >= 500) {
|
|
36
|
+
throw new Error(`HTTP ${res.status} — ${path}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const json = (await res.json()) as OpenApiResponse<T>
|
|
40
|
+
|
|
41
|
+
if (json.code !== 0 || !json.success) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Open API 错误 [${path}]: ${json.message ?? '未知错误'} (code: ${json.code})`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return json.data
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── GET 请求 ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export async function openApiGet<T>(
|
|
53
|
+
path: string,
|
|
54
|
+
params?: Record<string, string>
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
const url = new URL(path, BASE_URL)
|
|
57
|
+
if (params) {
|
|
58
|
+
for (const [k, v] of Object.entries(params)) {
|
|
59
|
+
url.searchParams.set(k, v)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const res = await fetch(url.toString(), {
|
|
64
|
+
method: 'GET',
|
|
65
|
+
headers: {
|
|
66
|
+
'X-API-KEY': API_KEY,
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return handleResponse<T>(res, path)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── POST 请求 ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export async function openApiPost<T>(
|
|
77
|
+
path: string,
|
|
78
|
+
body?: Record<string, unknown>
|
|
79
|
+
): Promise<T> {
|
|
80
|
+
const url = new URL(path, BASE_URL)
|
|
81
|
+
|
|
82
|
+
const res = await fetch(url.toString(), {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'X-API-KEY': API_KEY,
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
},
|
|
88
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return handleResponse<T>(res, path)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── 文件上传 ───────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export async function openApiUpload<T>(
|
|
97
|
+
path: string,
|
|
98
|
+
formData: FormData,
|
|
99
|
+
options?: {
|
|
100
|
+
params?: Record<string, string>
|
|
101
|
+
uploadToken?: string
|
|
102
|
+
}
|
|
103
|
+
): Promise<T> {
|
|
104
|
+
const url = new URL(path, BASE_URL)
|
|
105
|
+
if (options?.params) {
|
|
106
|
+
for (const [k, v] of Object.entries(options.params)) {
|
|
107
|
+
url.searchParams.set(k, v)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 上传使用 X-UPLOAD-TOKEN 认证(非 X-API-KEY)
|
|
112
|
+
const headerName = options?.uploadToken ? 'X-UPLOAD-TOKEN' : 'X-API-KEY'
|
|
113
|
+
const headerValue = options?.uploadToken ?? API_KEY
|
|
114
|
+
|
|
115
|
+
const res = await fetch(url.toString(), {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { [headerName]: headerValue },
|
|
118
|
+
body: formData,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return handleResponse<T>(res, path)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── 重试封装 ───────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
interface RetryOptions {
|
|
127
|
+
maxRetries: number
|
|
128
|
+
baseDelay: number
|
|
129
|
+
label?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function withRetry<T>(
|
|
133
|
+
fn: () => Promise<T>,
|
|
134
|
+
options: RetryOptions = { maxRetries: 3, baseDelay: 1000 }
|
|
135
|
+
): Promise<T> {
|
|
136
|
+
let lastError: Error | null = null
|
|
137
|
+
|
|
138
|
+
for (let attempt = 1; attempt <= options.maxRetries + 1; attempt++) {
|
|
139
|
+
try {
|
|
140
|
+
return await fn()
|
|
141
|
+
} catch (err) {
|
|
142
|
+
lastError = err as Error
|
|
143
|
+
|
|
144
|
+
if (attempt > options.maxRetries) break
|
|
145
|
+
|
|
146
|
+
// 只重试网络错误和 5xx
|
|
147
|
+
const msg = lastError.message
|
|
148
|
+
const isRetryable =
|
|
149
|
+
msg.includes('fetch failed') ||
|
|
150
|
+
msg.includes('ECONNRESET') ||
|
|
151
|
+
/HTTP 5\d\d/.test(msg)
|
|
152
|
+
if (!isRetryable) break
|
|
153
|
+
|
|
154
|
+
const delay = options.baseDelay * Math.pow(2, attempt - 1)
|
|
155
|
+
const label = options.label ?? 'API 调用'
|
|
156
|
+
console.warn(`${label} 第 ${attempt} 次失败,${(delay / 1000).toFixed(1)}s 后重试...`)
|
|
157
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw lastError
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── 辅助函数 ───────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export function sleep(ms: number): Promise<void> {
|
|
167
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 轮询间隔策略(阶梯递增)
|
|
172
|
+
*
|
|
173
|
+
* 前 6 次(0-30s):每 5 秒 → 覆盖大部分快速完成的场景
|
|
174
|
+
* 第 7-12 次(30s-90s):每 10 秒 → 中等耗时视频
|
|
175
|
+
* 第 13 次起(90s+):每 15 秒 → 长耗时视频,降低 API 压力
|
|
176
|
+
*/
|
|
177
|
+
export function getPollingInterval(pollCount: number): number {
|
|
178
|
+
if (pollCount <= 6) return 5_000
|
|
179
|
+
if (pollCount <= 12) return 10_000
|
|
180
|
+
return 15_000
|
|
181
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取 OAuth 授权 URL 示例
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* npx tsx get-oauth-url.ts --type tt
|
|
6
|
+
* npx tsx get-oauth-url.ts --type tts
|
|
7
|
+
*
|
|
8
|
+
* 生产环境:如果要往授权链接里追加你方自定义安全字段,
|
|
9
|
+
* 先判断现有 state 是否为 JSON 对象;若是,再在该 JSON 中追加字段。
|
|
10
|
+
* 详见 docs/oauth-callback.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { parseArgs } from 'node:util'
|
|
14
|
+
import { openApiGet } from './api-client.js'
|
|
15
|
+
|
|
16
|
+
const { values } = parseArgs({
|
|
17
|
+
options: {
|
|
18
|
+
type: { type: 'string', default: 'tt' },
|
|
19
|
+
},
|
|
20
|
+
strict: false,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const accountType = (values['type'] ?? 'tt').toLowerCase()
|
|
24
|
+
|
|
25
|
+
if (accountType !== 'tt' && accountType !== 'tts') {
|
|
26
|
+
console.error('错误: --type 必须为 tt 或 tts')
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`获取 ${accountType.toUpperCase()} OAuth 授权 URL...\n`)
|
|
31
|
+
|
|
32
|
+
if (accountType === 'tt') {
|
|
33
|
+
// TT 账号:返回值即 URL 字符串
|
|
34
|
+
const url = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
|
|
35
|
+
console.log('TT OAuth URL:')
|
|
36
|
+
console.log(url)
|
|
37
|
+
} else {
|
|
38
|
+
// TTS 账号:返回值是包含 crossBorderUrl 的对象
|
|
39
|
+
const data = await openApiGet<{ crossBorderUrl: string }>('/api/v1/open/thirdparty-auth/tts-url')
|
|
40
|
+
console.log('TTS OAuth URL (跨境):')
|
|
41
|
+
console.log(data.crossBorderUrl)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('\n请将 URL 提供给用户,用户在浏览器中完成授权后,你的回调 URL 将收到 accountId。')
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "beervid-example-standard",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=20.0.0"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"oauth": "tsx get-oauth-url.ts",
|
|
10
|
+
"tt": "tsx tt-publish-flow.ts",
|
|
11
|
+
"tts": "tsx tts-publish-flow.ts",
|
|
12
|
+
"products": "tsx query-products.ts"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"tsx": "^4.21.0",
|
|
16
|
+
"typescript": "^5.9.3"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS 商品查询与分页示例
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* npx tsx query-products.ts --creator-id open_user_abc
|
|
6
|
+
* npx tsx query-products.ts --creator-id open_user_abc --type shop --max-pages 3
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseArgs } from 'node:util'
|
|
10
|
+
import { openApiPost, withRetry } from './api-client.js'
|
|
11
|
+
|
|
12
|
+
const { values } = parseArgs({
|
|
13
|
+
options: {
|
|
14
|
+
'creator-id': { type: 'string' },
|
|
15
|
+
type: { type: 'string', default: 'all' },
|
|
16
|
+
'page-size': { type: 'string', default: '20' },
|
|
17
|
+
'max-pages': { type: 'string', default: '5' },
|
|
18
|
+
},
|
|
19
|
+
strict: false,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const creatorId = values['creator-id']
|
|
23
|
+
const productTypes = values['type'] === 'all' ? ['shop', 'showcase'] : [values['type'] ?? 'shop']
|
|
24
|
+
const pageSize = parseInt(values['page-size'] ?? '20', 10)
|
|
25
|
+
const maxPages = parseInt(values['max-pages'] ?? '5', 10)
|
|
26
|
+
|
|
27
|
+
if (!creatorId) {
|
|
28
|
+
console.error('用法: npx tsx query-products.ts --creator-id <id>')
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── 类型定义 ───────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface Product {
|
|
35
|
+
id: string
|
|
36
|
+
title: string
|
|
37
|
+
price?: unknown
|
|
38
|
+
images?: string[]
|
|
39
|
+
salesCount?: number
|
|
40
|
+
source?: string
|
|
41
|
+
reviewStatus?: string
|
|
42
|
+
inventoryStatus?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProductPage {
|
|
46
|
+
products?: Product[]
|
|
47
|
+
nextPageToken?: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── 查询商品 ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
console.log(`查询商品(创作者: ${creatorId})\n`)
|
|
53
|
+
|
|
54
|
+
const allProducts = new Map<string, Product>()
|
|
55
|
+
|
|
56
|
+
for (const productType of productTypes) {
|
|
57
|
+
console.log(`--- ${productType} ---`)
|
|
58
|
+
let pageToken = ''
|
|
59
|
+
let page = 0
|
|
60
|
+
|
|
61
|
+
while (page < maxPages) {
|
|
62
|
+
page++
|
|
63
|
+
console.log(` 第 ${page} 页...`)
|
|
64
|
+
|
|
65
|
+
const data = await withRetry(
|
|
66
|
+
() => openApiPost<ProductPage | ProductPage[]>(
|
|
67
|
+
'/api/v1/open/tts/products/query',
|
|
68
|
+
{
|
|
69
|
+
creatorUserOpenId: creatorId,
|
|
70
|
+
productType,
|
|
71
|
+
pageSize,
|
|
72
|
+
pageToken,
|
|
73
|
+
}
|
|
74
|
+
),
|
|
75
|
+
{ maxRetries: 2, baseDelay: 2000 }
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const groups = Array.isArray(data) ? data : [data]
|
|
79
|
+
let pageProductCount = 0
|
|
80
|
+
|
|
81
|
+
for (const group of groups) {
|
|
82
|
+
for (const product of group.products ?? []) {
|
|
83
|
+
pageProductCount++
|
|
84
|
+
if (!allProducts.has(product.id)) {
|
|
85
|
+
// 解析图片 URL
|
|
86
|
+
const images = (product.images ?? []).map((img) => {
|
|
87
|
+
const match = img.match(/url=([^,}]+)/)
|
|
88
|
+
return match?.[1]?.trim() ?? ''
|
|
89
|
+
}).filter(Boolean)
|
|
90
|
+
|
|
91
|
+
allProducts.set(product.id, {
|
|
92
|
+
...product,
|
|
93
|
+
images,
|
|
94
|
+
source: product.source ?? productType,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (group.nextPageToken === null || group.nextPageToken === undefined) {
|
|
100
|
+
pageToken = ''
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
pageToken = group.nextPageToken
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(` 获得 ${pageProductCount} 个商品`)
|
|
107
|
+
|
|
108
|
+
if (!pageToken) {
|
|
109
|
+
console.log(` 已到最后一页`)
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (pageToken) {
|
|
115
|
+
console.log(` ⚠ 已达到最大页数限制 (${maxPages}),仍有未拉取的分页`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── 结果汇总 ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const products = Array.from(allProducts.values())
|
|
122
|
+
const publishable = products.filter((p) => {
|
|
123
|
+
if (p.reviewStatus && p.reviewStatus.toUpperCase() !== 'APPROVED') return false
|
|
124
|
+
if (p.inventoryStatus && p.inventoryStatus.toUpperCase() !== 'IN_STOCK') return false
|
|
125
|
+
return true
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
console.log(`\n汇总:`)
|
|
129
|
+
console.log(` 总商品数: ${products.length}`)
|
|
130
|
+
console.log(` 可发布商品: ${publishable.length}`)
|
|
131
|
+
|
|
132
|
+
if (publishable.length > 0) {
|
|
133
|
+
const sorted = publishable.sort((a, b) => (b.salesCount ?? 0) - (a.salesCount ?? 0))
|
|
134
|
+
console.log('\n可发布商品(按销量降序):')
|
|
135
|
+
for (const [i, p] of sorted.slice(0, 10).entries()) {
|
|
136
|
+
console.log(` ${i + 1}. [${p.source}] ${p.title} | ID: ${p.id} | 销量: ${p.salesCount ?? 0}`)
|
|
137
|
+
}
|
|
138
|
+
if (sorted.length > 10) {
|
|
139
|
+
console.log(` ... 共 ${sorted.length} 个`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TT 完整发布流程 — 最佳实践示例
|
|
3
|
+
*
|
|
4
|
+
* 流程:上传视频 → 发布 → 轮询状态(阶梯递增间隔) → 查询视频数据
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* npx tsx tt-publish-flow.ts --file ./video.mp4 --business-id biz_123 [--caption "My video"]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { resolve, basename } from 'node:path'
|
|
12
|
+
import { parseArgs } from 'node:util'
|
|
13
|
+
import {
|
|
14
|
+
openApiPost,
|
|
15
|
+
openApiUpload,
|
|
16
|
+
withRetry,
|
|
17
|
+
sleep,
|
|
18
|
+
getPollingInterval,
|
|
19
|
+
} from './api-client.js'
|
|
20
|
+
|
|
21
|
+
// ─── 参数解析 ───────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const { values } = parseArgs({
|
|
24
|
+
options: {
|
|
25
|
+
file: { type: 'string' },
|
|
26
|
+
'business-id': { type: 'string' },
|
|
27
|
+
caption: { type: 'string', default: '' },
|
|
28
|
+
'max-polls': { type: 'string', default: '60' },
|
|
29
|
+
},
|
|
30
|
+
strict: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const filePath = values['file']
|
|
34
|
+
const businessId = values['business-id']
|
|
35
|
+
const caption = values['caption'] ?? ''
|
|
36
|
+
const maxPolls = parseInt(values['max-polls'] ?? '60', 10)
|
|
37
|
+
|
|
38
|
+
if (!filePath || !businessId) {
|
|
39
|
+
console.error('用法: npx tsx tt-publish-flow.ts --file <路径> --business-id <id> [--caption <text>]')
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── 步骤 1:获取上传凭证 ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
console.log('━'.repeat(60))
|
|
46
|
+
console.log('TT 完整发布流程')
|
|
47
|
+
console.log('━'.repeat(60))
|
|
48
|
+
|
|
49
|
+
console.log('\n[1/4] 获取上传凭证...')
|
|
50
|
+
|
|
51
|
+
const tokenData = await withRetry(
|
|
52
|
+
() => openApiPost<{ uploadToken: string; expiresIn: number }>(
|
|
53
|
+
'/api/v1/open/upload-token/generate'
|
|
54
|
+
),
|
|
55
|
+
{ maxRetries: 3, baseDelay: 1000, label: '获取上传凭证' }
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.log(` ✓ 获得上传凭证(有效期 ${tokenData.expiresIn} 秒)`)
|
|
59
|
+
|
|
60
|
+
// ─── 步骤 2:上传视频 ──────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
console.log('\n[2/4] 上传视频...')
|
|
63
|
+
|
|
64
|
+
const absPath = resolve(filePath)
|
|
65
|
+
const buffer = readFileSync(absPath)
|
|
66
|
+
const fileName = basename(absPath)
|
|
67
|
+
const file = new File([buffer], fileName, { type: 'video/mp4' })
|
|
68
|
+
|
|
69
|
+
console.log(` 文件: ${fileName} (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB)`)
|
|
70
|
+
|
|
71
|
+
const formData = new FormData()
|
|
72
|
+
formData.append('file', file)
|
|
73
|
+
|
|
74
|
+
const uploadResult = await withRetry(
|
|
75
|
+
() => openApiUpload<{ fileUrl: string }>(
|
|
76
|
+
'/api/v1/open/file-upload',
|
|
77
|
+
formData,
|
|
78
|
+
{ uploadToken: tokenData.uploadToken }
|
|
79
|
+
),
|
|
80
|
+
{ maxRetries: 2, baseDelay: 5000, label: '视频上传' }
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
console.log(` ✓ 上传成功: ${uploadResult.fileUrl}`)
|
|
84
|
+
|
|
85
|
+
// ─── 步骤 3:发布视频 ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
console.log('\n[3/4] 发布视频...')
|
|
88
|
+
|
|
89
|
+
// ⚠️ 发布操作不重试!重复调用会产生多条视频
|
|
90
|
+
const publishResult = await openApiPost<{ shareId: string }>(
|
|
91
|
+
'/api/v1/open/tiktok/video/publish',
|
|
92
|
+
{
|
|
93
|
+
businessId,
|
|
94
|
+
videoUrl: uploadResult.fileUrl,
|
|
95
|
+
caption,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
console.log(` ✓ 已提交发布,shareId: ${publishResult.shareId}`)
|
|
100
|
+
|
|
101
|
+
// ─── 步骤 4:轮询发布状态(阶梯递增间隔) ──────────────────────────────────
|
|
102
|
+
|
|
103
|
+
console.log('\n[4/4] 轮询发布状态...')
|
|
104
|
+
console.log(' 轮询策略:前 6 次每 5s → 7-12 次每 10s → 之后每 15s')
|
|
105
|
+
|
|
106
|
+
let videoId: string | null = null
|
|
107
|
+
|
|
108
|
+
for (let i = 1; i <= maxPolls; i++) {
|
|
109
|
+
const interval = getPollingInterval(i)
|
|
110
|
+
|
|
111
|
+
const statusData = await withRetry(
|
|
112
|
+
() => openApiPost<{
|
|
113
|
+
status?: string
|
|
114
|
+
Status?: string
|
|
115
|
+
reason?: string
|
|
116
|
+
post_ids?: string[]
|
|
117
|
+
}>('/api/v1/open/tiktok/video/status', {
|
|
118
|
+
businessId,
|
|
119
|
+
shareId: publishResult.shareId,
|
|
120
|
+
}),
|
|
121
|
+
{ maxRetries: 2, baseDelay: 1000, label: '轮询状态' }
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const status = statusData.status ?? statusData.Status ?? 'UNKNOWN'
|
|
125
|
+
const postIds = statusData.post_ids ?? []
|
|
126
|
+
|
|
127
|
+
// 状态判定
|
|
128
|
+
if (status === 'FAILED') {
|
|
129
|
+
console.error(` ✗ 发布失败: ${statusData.reason ?? '未知原因'}`)
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (status === 'PUBLISH_COMPLETE' && postIds.length > 0) {
|
|
134
|
+
videoId = postIds[0]!
|
|
135
|
+
console.log(` ✓ 发布完成!视频 ID: ${videoId}(第 ${i} 次查询)`)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 注意:PUBLISH_COMPLETE 但 post_ids 为空 → 继续轮询
|
|
140
|
+
const statusLabel = status === 'PUBLISH_COMPLETE' ? 'COMPLETE(等待 post_ids)' : status
|
|
141
|
+
console.log(` [${i}/${maxPolls}] 状态: ${statusLabel},${interval / 1000}s 后重试...`)
|
|
142
|
+
|
|
143
|
+
if (i < maxPolls) {
|
|
144
|
+
await sleep(interval)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!videoId) {
|
|
149
|
+
console.error(` ✗ 超时:${maxPolls} 次轮询后仍未获得视频 ID`)
|
|
150
|
+
process.exit(2)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── 查询视频数据 ──────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
console.log('\n[可选] 查询视频数据...')
|
|
156
|
+
|
|
157
|
+
// 视频刚发布后数据可能尚未同步,等待几秒后查询
|
|
158
|
+
await sleep(5000)
|
|
159
|
+
|
|
160
|
+
const queryData = await withRetry(
|
|
161
|
+
() => openApiPost<{
|
|
162
|
+
videoList?: Array<Record<string, unknown>>
|
|
163
|
+
videos?: Array<Record<string, unknown>>
|
|
164
|
+
}>('/api/v1/open/tiktok/video/query', {
|
|
165
|
+
businessId,
|
|
166
|
+
itemIds: [videoId],
|
|
167
|
+
}),
|
|
168
|
+
{ maxRetries: 3, baseDelay: 5000, label: '查询视频数据' }
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
// 兼容新旧字段格式
|
|
172
|
+
const videoList = queryData.videoList ?? queryData.videos ?? []
|
|
173
|
+
if (videoList.length > 0) {
|
|
174
|
+
const video = videoList[0]!
|
|
175
|
+
console.log(' ✓ 视频数据:')
|
|
176
|
+
console.log(` 播放量: ${video['videoViews'] ?? video['video_views'] ?? 0}`)
|
|
177
|
+
console.log(` 点赞: ${video['likes'] ?? 0}`)
|
|
178
|
+
console.log(` 评论: ${video['comments'] ?? 0}`)
|
|
179
|
+
console.log(` 分享: ${video['shares'] ?? 0}`)
|
|
180
|
+
console.log(` 链接: ${video['shareUrl'] ?? video['share_url'] ?? '-'}`)
|
|
181
|
+
} else {
|
|
182
|
+
console.log(' ⚠ 视频数据暂未同步,稍后可手动查询')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── 结果汇总 ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
console.log('\n' + '━'.repeat(60))
|
|
188
|
+
console.log('流程完成!')
|
|
189
|
+
console.log(JSON.stringify({
|
|
190
|
+
businessId,
|
|
191
|
+
fileUrl: uploadResult.fileUrl,
|
|
192
|
+
shareId: publishResult.shareId,
|
|
193
|
+
videoId,
|
|
194
|
+
}, null, 2))
|