| 1 | import type { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios' |
| 2 | import type { Readable } from 'node:stream' |
| 3 | import type { GenerateAuthUrlInput } from '../platforms.interface' |
| 4 | import type { KwaiPlatformResponseBody } from './kwai.exception' |
| 5 | import type { |
| 6 | KwaiApiResponse, |
| 7 | KwaiOAuthCredentialsResponse, |
| 8 | KwaiPhotoInfo, |
| 9 | KwaiPhotoListResponse, |
| 10 | KwaiPublishVideoResponse, |
| 11 | KwaiStartUploadResponse, |
| 12 | KwaiUserInfoResponse, |
| 13 | KwaiVideoUploadResponse, |
| 14 | } from './kwai.interface' |
| 15 | import { Injectable } from '@nestjs/common' |
| 16 | import axios from 'axios' |
| 17 | import { KwaiConfig } from './kwai.config' |
| 18 | import { KwaiOAuthGrantType } from './kwai.constants' |
| 19 | import { KwaiPlatformException } from './kwai.exception' |
| 20 | |
| 21 | const KWAI_API_HOST = 'https://open.kuaishou.com' |
| 22 | |
| 23 | @Injectable() |
| 24 | export class KwaiService { |
| 25 | private readonly http: AxiosInstance |
| 26 | |
| 27 | constructor(private readonly cfg: KwaiConfig) { |
| 28 | this.http = this.createHttpClient() |
| 29 | } |
| 30 | |
| 31 | private createHttpClient(): AxiosInstance { |
| 32 | const http = axios.create() |
| 33 | http.interceptors.response.use( |
| 34 | (response) => { |
| 35 | if (KwaiPlatformException.hasPlatformError(response)) { |
| 36 | throw KwaiPlatformException.fromPlatformResponse(response) |
| 37 | } |
| 38 | return response |
| 39 | }, |
| 40 | (error: AxiosError<KwaiPlatformResponseBody>) => { |
| 41 | throw KwaiPlatformException.fromAxiosError(error) |
| 42 | }, |
| 43 | ) |
| 44 | return http |
| 45 | } |
| 46 | |
| 47 | private async request<T>( |
| 48 | url: string, |
| 49 | config: AxiosRequestConfig = {}, |
| 50 | ): Promise<KwaiApiResponse<T>> { |
| 51 | const response = await this.http.request<KwaiApiResponse<T>>({ |
| 52 | ...config, |
| 53 | method: config.method ?? 'GET', |
| 54 | url, |
| 55 | }) |
| 56 | return response.data |
| 57 | } |
| 58 | |
| 59 | generateAuthUrl( |
| 60 | scopes: string[], |
| 61 | state: string, |
| 62 | deviceType?: GenerateAuthUrlInput['deviceType'], |
| 63 | ): string { |
| 64 | const params = new URLSearchParams({ |
| 65 | app_id: this.cfg.clientId, |
| 66 | scope: scopes.join(','), |
| 67 | response_type: 'code', |
| 68 | state, |
| 69 | redirect_uri: this.cfg.redirectUri, |
| 70 | ...(deviceType === 'desktop' ? { ua: 'pc' } : {}), |
| 71 | }) |
| 72 | |
| 73 | return `${KWAI_API_HOST}/oauth2/authorize?${params.toString()}` |
| 74 | } |
| 75 | |
| 76 | async exchangeCode(code: string): Promise<{ |
| 77 | accessToken: string |
| 78 | refreshToken: string |
| 79 | expiresAt: Date |
| 80 | scope: string |
| 81 | openId: string |
| 82 | }> { |
| 83 | const params = { |
| 84 | app_id: this.cfg.clientId, |
| 85 | app_secret: this.cfg.clientSecret, |
| 86 | code, |
| 87 | grant_type: KwaiOAuthGrantType.AuthorizationCode, |
| 88 | } |
| 89 | |
| 90 | const url = `${KWAI_API_HOST}/oauth2/access_token` |
| 91 | const data = await this.request<KwaiOAuthCredentialsResponse>( |
| 92 | url, |
| 93 | { method: 'GET', params }, |
| 94 | ) |
| 95 | |
| 96 | return { |
| 97 | accessToken: data.access_token, |
| 98 | refreshToken: data.refresh_token, |
| 99 | expiresAt: new Date(Date.now() + data.expires_in * 1000), |
| 100 | scope: data.scopes.join(','), |
| 101 | openId: data.open_id, |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | async refreshToken(refreshToken: string): Promise<{ |
| 106 | accessToken: string |
| 107 | refreshToken: string |
| 108 | expiresAt: Date |
| 109 | scope: string |
| 110 | }> { |
| 111 | const params = { |
| 112 | app_id: this.cfg.clientId, |
| 113 | app_secret: this.cfg.clientSecret, |
| 114 | refresh_token: refreshToken, |
| 115 | grant_type: KwaiOAuthGrantType.RefreshToken, |
| 116 | } |
| 117 | |
| 118 | const url = `${KWAI_API_HOST}/oauth2/refresh_token` |
| 119 | const data = await this.request<KwaiOAuthCredentialsResponse>( |
| 120 | url, |
| 121 | { params }, |
| 122 | ) |
| 123 | |
| 124 | return { |
| 125 | accessToken: data.access_token, |
| 126 | refreshToken: data.refresh_token, |
| 127 | expiresAt: new Date(Date.now() + data.expires_in * 1000), |
| 128 | scope: data.scopes.join(','), |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | async getUserInfo(accessToken: string): Promise<{ |
| 133 | displayName: string |
| 134 | avatarUrl?: string |
| 135 | sex?: string |
| 136 | fanCount?: number |
| 137 | followCount?: number |
| 138 | city?: string |
| 139 | }> { |
| 140 | const params = { |
| 141 | app_id: this.cfg.clientId, |
| 142 | access_token: accessToken, |
| 143 | } |
| 144 | |
| 145 | const url = `${KWAI_API_HOST}/openapi/user_info` |
| 146 | const data = await this.request<KwaiUserInfoResponse>( |
| 147 | url, |
| 148 | { params }, |
| 149 | ) |
| 150 | |
| 151 | const userInfo = data.user_info |
| 152 | return { |
| 153 | displayName: userInfo.name, |
| 154 | avatarUrl: userInfo.bigHead || userInfo.head, |
| 155 | sex: userInfo.sex, |
| 156 | fanCount: userInfo.fan, |
| 157 | followCount: userInfo.follow, |
| 158 | city: userInfo.city, |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | async listPhotos( |
| 163 | accessToken: string, |
| 164 | cursor?: string, |
| 165 | count?: number, |
| 166 | ): Promise<KwaiPhotoInfo[]> { |
| 167 | const page = await this.listPhotoPage(accessToken, cursor, count) |
| 168 | return page.items |
| 169 | } |
| 170 | |
| 171 | async listPhotoPage( |
| 172 | accessToken: string, |
| 173 | cursor?: string, |
| 174 | count?: number, |
| 175 | ): Promise<{ |
| 176 | items: KwaiPhotoInfo[] |
| 177 | rawResponse: KwaiPhotoListResponse |
| 178 | }> { |
| 179 | const params = { |
| 180 | app_id: this.cfg.clientId, |
| 181 | access_token: accessToken, |
| 182 | cursor, |
| 183 | count, |
| 184 | } |
| 185 | |
| 186 | const url = `${KWAI_API_HOST}/openapi/photo/list` |
| 187 | const data = await this.request<KwaiPhotoListResponse>( |
| 188 | url, |
| 189 | { params }, |
| 190 | ) |
| 191 | |
| 192 | return { |
| 193 | items: data.video_list ?? [], |
| 194 | rawResponse: data, |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | async startUpload(accessToken: string): Promise<{ |
| 199 | uploadToken: string |
| 200 | endpoint: string |
| 201 | }> { |
| 202 | const params = { |
| 203 | app_id: this.cfg.clientId, |
| 204 | access_token: accessToken, |
| 205 | } |
| 206 | |
| 207 | const url = `${KWAI_API_HOST}/openapi/photo/start_upload` |
| 208 | const data = await this.request<KwaiStartUploadResponse>( |
| 209 | url, |
| 210 | { method: 'POST', params }, |
| 211 | ) |
| 212 | |
| 213 | return { |
| 214 | uploadToken: data.upload_token, |
| 215 | endpoint: data.endpoint, |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | async fragmentUploadVideo( |
| 220 | uploadToken: string, |
| 221 | fragmentId: number, |
| 222 | endpoint: string, |
| 223 | video: Buffer | Readable, |
| 224 | contentLength?: number, |
| 225 | ): Promise<void> { |
| 226 | const params = { |
| 227 | fragment_id: fragmentId, |
| 228 | upload_token: uploadToken, |
| 229 | } |
| 230 | const headers: Record<string, string> = { 'Content-Type': 'video/mp4' } |
| 231 | if (contentLength !== undefined) { |
| 232 | headers['Content-Length'] = String(contentLength) |
| 233 | } |
| 234 | |
| 235 | const url = `http://${endpoint}/api/upload/fragment` |
| 236 | await this.request<KwaiVideoUploadResponse>( |
| 237 | url, |
| 238 | { |
| 239 | method: 'POST', |
| 240 | params, |
| 241 | headers, |
| 242 | data: video, |
| 243 | }, |
| 244 | ) |
| 245 | } |
| 246 | |
| 247 | async completeFragmentUpload( |
| 248 | uploadToken: string, |
| 249 | fragmentCount: number, |
| 250 | endpoint: string, |
| 251 | ): Promise<void> { |
| 252 | const params = { |
| 253 | fragment_count: fragmentCount, |
| 254 | upload_token: uploadToken, |
| 255 | } |
| 256 | |
| 257 | const url = `http://${endpoint}/api/upload/complete` |
| 258 | await this.request<KwaiVideoUploadResponse>( |
| 259 | url, |
| 260 | { method: 'POST', params }, |
| 261 | ) |
| 262 | } |
| 263 | |
| 264 | async publishVideo( |
| 265 | accessToken: string, |
| 266 | caption: string, |
| 267 | cover: Blob, |
| 268 | uploadToken: string, |
| 269 | option?: { |
| 270 | stereo_type?: string |
| 271 | merchant_product_id?: string |
| 272 | }, |
| 273 | ): Promise<{ photoId: string, playUrl?: string }> { |
| 274 | const formData = new FormData() |
| 275 | formData.append('caption', caption) |
| 276 | formData.append('cover', cover) |
| 277 | |
| 278 | const params = { |
| 279 | upload_token: uploadToken, |
| 280 | app_id: this.cfg.clientId, |
| 281 | access_token: accessToken, |
| 282 | stereo_type: option?.stereo_type, |
| 283 | merchant_product_id: option?.merchant_product_id, |
| 284 | } |
| 285 | |
| 286 | const url = `${KWAI_API_HOST}/openapi/photo/publish` |
| 287 | const data = await this.request<KwaiPublishVideoResponse>( |
| 288 | url, |
| 289 | { |
| 290 | method: 'POST', |
| 291 | params, |
| 292 | data: formData, |
| 293 | }, |
| 294 | ) |
| 295 | |
| 296 | return { |
| 297 | photoId: data.video_info.photo_id, |
| 298 | playUrl: data.video_info.play_url, |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | async getVideoInfo( |
| 303 | accessToken: string, |
| 304 | photoId: string, |
| 305 | ): Promise<{ |
| 306 | photoId: string |
| 307 | caption: string |
| 308 | cover: string |
| 309 | playUrl: string |
| 310 | createTime: number |
| 311 | likeCount: number |
| 312 | commentCount: number |
| 313 | viewCount: number |
| 314 | pending: boolean |
| 315 | }> { |
| 316 | const params = { |
| 317 | app_id: this.cfg.clientId, |
| 318 | access_token: accessToken, |
| 319 | photo_id: photoId, |
| 320 | } |
| 321 | |
| 322 | const url = `${KWAI_API_HOST}/openapi/photo/info` |
| 323 | const data = await this.request<{ video_info: KwaiPublishVideoResponse['video_info'] }>( |
| 324 | url, |
| 325 | { params }, |
| 326 | ) |
| 327 | |
| 328 | const video = data.video_info |
| 329 | return { |
| 330 | photoId: video.photo_id, |
| 331 | caption: video.caption, |
| 332 | cover: video.cover, |
| 333 | playUrl: video.play_url ?? '', |
| 334 | createTime: video.create_time, |
| 335 | likeCount: video.like_count, |
| 336 | commentCount: video.comment_count, |
| 337 | viewCount: video.view_count, |
| 338 | pending: video.pending, |
| 339 | } |
| 340 | } |
| 341 | } |
| 342 |