返回 AiToEarn
tiktok.service.ts
1 import type { AxiosError, AxiosInstance } from 'axios'
2 import type { TikTokPlatformResponseBody } from './tiktok.exception'
3 import type {
4 TikTokApiResponse,
5 TikTokContentRequestBody,
6 TikTokCreatorInfo,
7 TikTokOAuthResponse,
8 TikTokPhotoPostInfo,
9 TikTokPhotoSourceInfo,
10 TikTokPublishResponse,
11 TikTokPublishStatusResponse,
12 TikTokRequestOptions,
13 TikTokUploadPlan,
14 TikTokUserInfo,
15 TikTokVideoPostInfo,
16 TikTokVideoQueryResponse,
17 TikTokVideoSourceInfo,
18 } from './tiktok.interface'
19 import { Injectable } from '@nestjs/common'
20 import { AccountType, ResponseCode } from '@yikart/common'
21 import axios from 'axios'
22 import { isSafeNumber, parse } from 'lossless-json'
23 import { MediaService } from '../../media/media.service'
24 import { PlatformErrorCategory } from '../platforms.exception'
25 import { TiktokConfig } from './tiktok.config'
26 import { TikTokPlatformException } from './tiktok.exception'
27 import { TikTokOAuthGrantType } from './tiktok.interface'
28
29 const MIN_CHUNK_SIZE = 5 * 1024 * 1024
30 const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024
31
32 @Injectable()
33 export class TikTokService {
34 private readonly http: AxiosInstance
35
36 constructor(
37 private readonly cfg: TiktokConfig,
38 private readonly mediaService: MediaService,
39 ) {
40 this.http = this.createHttpClient()
41 }
42
43 private readonly apiBaseUrl = 'https://open.tiktokapis.com/v2'
44 private readonly authUrl = 'https://www.tiktok.com/v2/auth/authorize'
45
46 private createHttpClient(): AxiosInstance {
47 const http = axios.create()
48 http.interceptors.response.use(
49 (response) => {
50 if (TikTokPlatformException.hasPlatformError(response)) {
51 throw TikTokPlatformException.fromPlatformResponse(response)
52 }
53 return response
54 },
55 (error: AxiosError<TikTokPlatformResponseBody>) => {
56 throw TikTokPlatformException.fromAxiosError(error)
57 },
58 )
59 return http
60 }
61
62 private async apiRequest<T>(
63 url: string,
64 options: TikTokRequestOptions = {},
65 accessToken?: string,
66 ): Promise<T> {
67 const headers: Record<string, string> = accessToken
68 ? {
69 ...(options.headers ?? {}),
70 'Authorization': `Bearer ${accessToken}`,
71 'Content-Type': 'application/json; charset=UTF-8',
72 }
73 : {
74 ...(options.headers ?? {}),
75 }
76
77 const response = await this.http.request<TikTokApiResponse<T>>({
78 ...options,
79 method: options.method ?? 'GET',
80 url,
81 headers,
82 })
83
84 return response.data.data
85 }
86
87 private async contentRequest<T>(
88 url: string,
89 data: TikTokContentRequestBody,
90 accessToken: string,
91 ): Promise<T> {
92 const response = await this.http.post<TikTokApiResponse<T>>(
93 url,
94 data,
95 {
96 headers: {
97 'Authorization': `Bearer ${accessToken}`,
98 'Content-Type': 'application/json; charset=UTF-8',
99 },
100 },
101 )
102
103 return response.data.data
104 }
105
106 private async oauthRequest<T>(
107 url: string,
108 data: URLSearchParams,
109 ): Promise<T> {
110 const response = await this.http.post<T>(url, data, {
111 headers: {
112 'Content-Type': 'application/x-www-form-urlencoded',
113 },
114 })
115
116 return response.data
117 }
118
119 generateAuthUrl(scopes: string[], state: string, codeChallenge?: string): string {
120 const params = new URLSearchParams({
121 client_key: this.cfg.clientId,
122 scope: scopes.join(','),
123 response_type: 'code',
124 redirect_uri: this.cfg.redirectUri,
125 state,
126 })
127
128 if (codeChallenge) {
129 params.set('code_challenge', codeChallenge)
130 params.set('code_challenge_method', 'S256')
131 }
132
133 return `${this.authUrl}?${params.toString()}`
134 }
135
136 async exchangeCode(code: string, codeVerifier?: string): Promise<{
137 accessToken: string
138 refreshToken: string
139 expiresAt: Date
140 refreshExpiresAt: Date
141 openId: string
142 scope: string
143 }> {
144 const data = new URLSearchParams({
145 client_key: this.cfg.clientId,
146 client_secret: this.cfg.clientSecret,
147 code,
148 grant_type: TikTokOAuthGrantType.AuthorizationCode,
149 redirect_uri: this.cfg.redirectUri,
150 })
151
152 if (codeVerifier) {
153 data.set('code_verifier', codeVerifier)
154 }
155
156 const result = await this.oauthRequest<TikTokOAuthResponse>(
157 `${this.apiBaseUrl}/oauth/token/`,
158 data,
159 )
160
161 return {
162 accessToken: result.access_token,
163 refreshToken: result.refresh_token,
164 expiresAt: new Date(Date.now() + result.expires_in * 1000),
165 refreshExpiresAt: new Date(Date.now() + result.refresh_expires_in * 1000),
166 openId: result.open_id,
167 scope: result.scope,
168 }
169 }
170
171 async refreshAccessToken(refreshToken: string): Promise<{
172 accessToken: string
173 refreshToken: string
174 expiresAt: Date
175 refreshExpiresAt: Date
176 scope: string
177 }> {
178 const result = await this.oauthRequest<TikTokOAuthResponse>(
179 `${this.apiBaseUrl}/oauth/token/`,
180 new URLSearchParams({
181 client_key: this.cfg.clientId,
182 client_secret: this.cfg.clientSecret,
183 grant_type: TikTokOAuthGrantType.RefreshToken,
184 refresh_token: refreshToken,
185 }),
186 )
187
188 return {
189 accessToken: result.access_token,
190 refreshToken: result.refresh_token,
191 expiresAt: new Date(Date.now() + result.expires_in * 1000),
192 refreshExpiresAt: new Date(Date.now() + result.refresh_expires_in * 1000),
193 scope: result.scope,
194 }
195 }
196
197 async revokeAccessToken(accessToken: string): Promise<void> {
198 await this.oauthRequest(
199 `${this.apiBaseUrl}/oauth/revoke/`,
200 new URLSearchParams({
201 client_key: this.cfg.clientId,
202 client_secret: this.cfg.clientSecret,
203 token: accessToken,
204 }),
205 )
206 }
207
208 async getUserInfo(accessToken: string, fields = 'open_id,union_id,avatar_url,username,display_name,bio_description,follower_count,following_count,likes_count,video_count'): Promise<{
209 openId: string
210 unionId?: string
211 avatarUrl?: string
212 username?: string
213 displayName?: string
214 bioDescription?: string
215 followerCount?: number
216 followingCount?: number
217 likesCount?: number
218 videoCount?: number
219 }> {
220 const result = await this.apiRequest<{ user: TikTokUserInfo }>(
221 `${this.apiBaseUrl}/user/info/`,
222 {
223 params: {
224 fields,
225 },
226 },
227 accessToken,
228 )
229
230 const user = result.user
231 return {
232 openId: user.open_id,
233 unionId: user.union_id,
234 avatarUrl: user.avatar_url,
235 username: user.username,
236 displayName: user.display_name,
237 bioDescription: user.bio_description,
238 followerCount: user.follower_count,
239 followingCount: user.following_count,
240 likesCount: user.likes_count,
241 videoCount: user.video_count,
242 }
243 }
244
245 async getCreatorInfo(accessToken: string): Promise<TikTokCreatorInfo> {
246 return this.contentRequest<TikTokCreatorInfo>(
247 `${this.apiBaseUrl}/post/publish/creator_info/query/`,
248 {},
249 accessToken,
250 )
251 }
252
253 async listVideos(
254 accessToken: string,
255 cursor?: string,
256 limit = 20,
257 fields?: string,
258 ): Promise<TikTokVideoQueryResponse> {
259 const response = await this.http.post<TikTokApiResponse<TikTokVideoQueryResponse>>(
260 `${this.apiBaseUrl}/video/list/`,
261 {
262 max_count: limit,
263 cursor,
264 },
265 {
266 params: { fields },
267 headers: {
268 'Authorization': `Bearer ${accessToken}`,
269 'Content-Type': 'application/json; charset=UTF-8',
270 },
271 },
272 )
273 return response.data.data
274 }
275
276 async queryVideos(
277 accessToken: string,
278 videoIds: string[],
279 ): Promise<TikTokVideoQueryResponse> {
280 const response = await this.http.post<TikTokApiResponse<TikTokVideoQueryResponse>>(
281 `${this.apiBaseUrl}/video/query/`,
282 {
283 filters: { video_ids: videoIds },
284 },
285 {
286 headers: {
287 'Authorization': `Bearer ${accessToken}`,
288 'Content-Type': 'application/json; charset=UTF-8',
289 },
290 params: {
291 fields: 'id,create_time,title,video_description,duration,cover_image_url,share_url,embed_link,view_count,like_count,comment_count,share_count',
292 },
293 },
294 )
295 return response.data.data
296 }
297
298 async initVideoPublish(
299 accessToken: string,
300 postInfo: TikTokVideoPostInfo,
301 sourceInfo: TikTokVideoSourceInfo,
302 ): Promise<TikTokPublishResponse> {
303 return this.contentRequest<TikTokPublishResponse>(
304 `${this.apiBaseUrl}/post/publish/video/init/`,
305 {
306 post_info: postInfo,
307 source_info: sourceInfo,
308 },
309 accessToken,
310 )
311 }
312
313 async initPhotoPublish(
314 accessToken: string,
315 postInfo: TikTokPhotoPostInfo,
316 sourceInfo: TikTokPhotoSourceInfo,
317 ): Promise<TikTokPublishResponse> {
318 return this.contentRequest<TikTokPublishResponse>(
319 `${this.apiBaseUrl}/post/publish/content/init/`,
320 {
321 media_type: 'PHOTO',
322 post_mode: 'DIRECT_POST',
323 post_info: postInfo,
324 source_info: sourceInfo,
325 },
326 accessToken,
327 )
328 }
329
330 async getPublishStatus(
331 accessToken: string,
332 publishId: string,
333 ): Promise<TikTokPublishStatusResponse> {
334 const response = await this.http.post<TikTokApiResponse<TikTokPublishStatusResponse>>(
335 `${this.apiBaseUrl}/post/publish/status/fetch/`,
336 { publish_id: publishId },
337 {
338 headers: {
339 'Authorization': `Bearer ${accessToken}`,
340 'Content-Type': 'application/json; charset=UTF-8',
341 },
342 responseType: 'text',
343 transformResponse: [(data: string) => parse(data, undefined, {
344 parseNumber: value => isSafeNumber(value) ? Number(value) : value,
345 })],
346 },
347 )
348 return response.data.data
349 }
350
351 async cancelPublish(
352 accessToken: string,
353 publishId: string,
354 ): Promise<void> {
355 await this.contentRequest(
356 `${this.apiBaseUrl}/post/publish/cancel/`,
357 { publish_id: publishId },
358 accessToken,
359 )
360 }
361
362 async uploadVideoFile(
363 uploadUrl: string,
364 video: Blob,
365 fileSize: number,
366 contentType = 'video/mp4',
367 ): Promise<void> {
368 await this.http.put(uploadUrl, video, {
369 headers: {
370 'Content-Type': contentType,
371 'Content-Length': fileSize,
372 'Content-Range': `bytes 0-${fileSize - 1}/${fileSize}`,
373 },
374 })
375 }
376
377 async chunkedUploadVideoFile(
378 uploadUrl: string,
379 video: Blob,
380 range: [number, number],
381 fileSize: number,
382 contentType = 'video/mp4',
383 ): Promise<void> {
384 await this.http.put(uploadUrl, video, {
385 headers: {
386 'Content-Type': contentType,
387 'Content-Length': range[1] - range[0] + 1,
388 'Content-Range': `bytes ${range[0]}-${range[1]}/${fileSize}`,
389 },
390 })
391 }
392
393 async uploadVideo(
394 uploadUrl: string,
395 videoUrl: string,
396 ): Promise<void> {
397 await this.mediaService.withUploadSource({
398 platform: AccountType.TikTok,
399 endpoint: 'downloadVideo',
400 url: videoUrl,
401 }, async (source) => {
402 const totalSize = source.sizeBytes
403 const contentType = source.contentType ?? 'video/mp4'
404
405 if (totalSize <= MAX_SINGLE_CHUNK_SIZE) {
406 await this.uploadVideoFile(uploadUrl, await source.blob(), totalSize, contentType)
407 return
408 }
409
410 const plan = this.getUploadPlan(totalSize)
411
412 for (const [chunkStart, chunkEnd] of plan.ranges) {
413 await this.chunkedUploadVideoFile(
414 uploadUrl,
415 await source.blob({ start: chunkStart, end: chunkEnd }),
416 [chunkStart, chunkEnd],
417 totalSize,
418 contentType,
419 )
420 }
421 })
422 }
423
424 getUploadPlan(fileSize: number): TikTokUploadPlan {
425 if (fileSize <= 0) {
426 throw TikTokPlatformException.fromPlatformError({
427 code: ResponseCode.ChannelPlatformApiFailed,
428 category: PlatformErrorCategory.Unknown,
429 context: { endpoint: 'getUploadPlan' },
430 platformCode: 'invalid_file_size',
431 })
432 }
433
434 if (fileSize <= MAX_SINGLE_CHUNK_SIZE) {
435 return {
436 chunkSize: fileSize,
437 totalChunkCount: 1,
438 ranges: [[0, fileSize - 1]],
439 }
440 }
441
442 const chunkSize = 10 * 1024 * 1024
443 const totalChunkCount = Math.floor(fileSize / chunkSize)
444
445 const ranges: Array<[number, number]> = []
446
447 for (let index = 0; index < totalChunkCount; index++) {
448 const start = index * chunkSize
449 const isLastChunk = index === totalChunkCount - 1
450 const end = isLastChunk ? fileSize - 1 : start + chunkSize - 1
451 ranges.push([start, end])
452 }
453
454 const resolvedLastChunkSize = ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] + 1
455 if (
456 chunkSize < MIN_CHUNK_SIZE
457 || chunkSize > MAX_SINGLE_CHUNK_SIZE
458 || resolvedLastChunkSize < MIN_CHUNK_SIZE
459 || resolvedLastChunkSize > 128 * 1024 * 1024
460 || totalChunkCount > 1000
461 ) {
462 throw TikTokPlatformException.fromPlatformError({
463 code: ResponseCode.ChannelPlatformApiFailed,
464 category: PlatformErrorCategory.Unknown,
465 context: { endpoint: 'getUploadPlan' },
466 platformCode: 'invalid_chunk_plan',
467 })
468 }
469
470 return {
471 chunkSize,
472 totalChunkCount,
473 ranges,
474 }
475 }
476 }
477
477 lines TYPESCRIPT