返回 AiToEarn
bilibili.service.ts
根目录 / project / aitoearn-electron / server / src / modules / plat / bilibili / bilibili.service.ts
1 /*
2 * @Author: nevin
3 * @Date: 2025-02-15 20:59:55
4 * @LastEditTime: 2025-04-27 17:58:21
5 * @LastEditors: nevin
6 * @Description: b站
7 */
8 import { Injectable } from '@nestjs/common';
9 import axios from 'axios';
10 import { v4 as uuidv4 } from 'uuid';
11 import { createHash, createHmac } from 'crypto';
12 import { AccessToken, BClient, VideoUTypes } from './comment';
13 import { ConfigService } from '@nestjs/config';
14 import { getRandomString } from 'src/util';
15 import { RedisService } from 'src/lib/redis/redis.service';
16 import { getCurrentTimestamp } from 'src/util/time.util';
17 @Injectable()
18 export class BilibiliService {
19 private clientId = '';
20 private clientSecret = '';
21 private clientName = '';
22 private authBackUrl = '';
23 constructor(
24 private readonly configService: ConfigService,
25 private readonly redisService: RedisService,
26 ) {
27 const cfg = this.configService.get<BClient>('BILIBILI_CONFIG');
28 this.clientId = cfg.clientId;
29 this.clientSecret = cfg.clientSecret;
30 this.clientName = cfg.clientName;
31 this.authBackUrl = cfg.authBackUrl;
32 }
33
34 /**
35 * 获取用户的授权链接
36 * @param userId
37 * @returns
38 */
39 async getAuthUrl(userId: string, type: 'h5' | 'pc') {
40 const gourl = encodeURIComponent(
41 `${this.authBackUrl}/api/plat/bilibili/auth/back/${userId}`,
42 );
43
44 const state = getRandomString(8);
45
46 this.redisService.setKey(`bilibili:state:${state}`, { userId }, 60 * 5);
47
48 if (type === 'h5')
49 return `https://account.bilibili.com/h5/account-h5/auth/oauth?navhide=1&callback=close&gourl=${gourl}&client_id=${this.clientId}&state=${state}`;
50
51 return `https://account.bilibili.com/pc/account-pc/auth/oauth?client_id=${this.clientId}&gourl=${gourl}&state=${state}`;
52 }
53
54 /**
55 * 设置用户的授权Token
56 * @param data
57 * @returns
58 */
59 async setUserAccessToken(data: {
60 code: string;
61 userId: string;
62 state: string;
63 }) {
64 const { code, userId, state } = data;
65
66 const query = {
67 client_id: this.clientId,
68 client_secret: this.clientSecret,
69 grant_type: 'authorization_code',
70 code,
71 };
72
73 const stateData = await this.redisService.get(`bilibili:state:${state}`);
74 if (!stateData || stateData.userId !== userId) return false;
75
76 try {
77 const result = await axios.post<{
78 code: number; // 0;
79 message: string; // '0';
80 ttl: number; // 1;
81 data: AccessToken;
82 }>('https://api.bilibili.com/x/account-oauth2/v1/token', null, {
83 params: query,
84 });
85
86 const accessTokenInfo = result.data.data;
87
88 // 剩余有效秒数
89 const expires =
90 accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60;
91
92 this.redisService.setKey(
93 `bilibili:accessToken:${userId}`,
94 accessTokenInfo,
95 expires,
96 );
97
98 return true;
99 } catch (error) {
100 console.log('Error during getUserAccessToken:', error);
101 return false;
102 }
103 }
104
105 async getUserAccessToken(userId: string): Promise<string> {
106 const res: AccessToken = await this.redisService.get(
107 `bilibili:accessToken:${userId}`,
108 );
109 if (!res) return '';
110
111 // 剩余时间
112 const overTime = res.expires_in - getCurrentTimestamp();
113
114 if (overTime < 60 * 60 && overTime > 0) {
115 // 刷新token
116 this.refreshAccessToken(userId, res.refresh_token);
117 }
118
119 return res.access_token;
120 }
121
122 /**
123 * 刷新授权Token
124 * @param userId
125 * @param refreshToken
126 * @returns
127 */
128 async refreshAccessToken(userId: string, refreshToken: string) {
129 const query = {
130 client_id: this.clientId,
131 client_secret: this.clientSecret,
132 grant_type: 'refresh_token',
133 refresh_token: refreshToken,
134 };
135
136 const url = `https://api.bilibili.com/x/account-oauth2/v1/refresh_token`;
137 try {
138 const result = await axios.post<{
139 code: number; // 0;
140 message: string; // '0';
141 ttl: number; // 1;
142 data: AccessToken;
143 }>(url, null, { params: query });
144
145 const accessTokenInfo = result.data.data;
146
147 // 剩余有效秒数
148 const expires =
149 accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60;
150
151 this.redisService.setKey(
152 `bilibili:accessToken:${userId}`,
153 accessTokenInfo,
154 expires,
155 );
156
157 return true;
158 } catch (error) {
159 console.log('Error during getAccessToken:', error);
160 return false;
161 }
162 }
163
164 /**
165 * 查询用户已授权权限列表
166 * @returns
167 */
168 async getAccountScopes(accessToken: string) {
169 const url = `https://member.bilibili.com/arcopen/fn/user/account/scopes`;
170 const result = await axios.get<{
171 code: number; // 0;
172 message: string; // '0';
173 ttl: number; // 1;
174 data: {
175 openid: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d';
176 scopes: string[]; // ['USER_INFO', 'ATC_DATA', 'ATC_BASE'];
177 };
178 }>(url, {
179 headers: this.generateBilibiliHeader({
180 accessToken,
181 isForm: true,
182 }),
183 });
184
185 return result.data.data;
186 }
187
188 /**
189 * 生成请求头
190 * @param data
191 */
192 private generateBilibiliHeader(data: {
193 accessToken: string;
194 body?: { [key: string]: any };
195 isForm?: boolean;
196 }) {
197 const { accessToken, body, isForm } = data;
198 const xBiliContentMd5 = body
199 ? createHash('md5').update(JSON.stringify(body)).digest('hex')
200 : '';
201
202 const header = {
203 Accept: 'application/json',
204 'Content-Type': isForm ? 'multipart/form-data' : 'application/json', // 或者 multipart/form-data
205 'x-bili-content-md5': xBiliContentMd5,
206 'x-bili-timestamp': Math.floor(Date.now() / 1000),
207 'x-bili-signature-method': 'HMAC-SHA256',
208 'x-bili-signature-nonce': uuidv4(),
209 'x-bili-accesskeyid': this.clientId,
210 'x-bili-signature-version': '1.0',
211 'access-token': accessToken, // 需要在请求头中添加access-token
212 Authorization: '',
213 };
214
215 // 抽取带”x-bili-“前缀的自定义header,按字典排序拼接,构建完整的待签名字符串:
216 // 待签名字符串包含换行符\n
217 const headerStr = Object.keys(header)
218 .filter((key) => key.startsWith('x-bili-'))
219 .sort()
220 .map((key) => `${key}:${header[key]}\n`)
221 .join('');
222
223 // 使用 createHmac 正确创建签名
224 const signature = createHmac('sha256', this.clientSecret)
225 .update(headerStr)
226 .digest('hex');
227
228 // 将签名加入 header
229 header.Authorization = signature;
230
231 return header;
232 }
233
234 /**
235 * 视频初始化
236 * @param fileName
237 * @param utype // 1-单个小文件(不超过100M)。默认值为0
238 * @returns
239 */
240 async videoInit(fileName: string, utype: VideoUTypes = 0): Promise<string> {
241 const body = {
242 name: fileName, // test.mp4
243 utype,
244 };
245
246 const url = `https://member.bilibili.com/arcopen/fn/archive/video/init`;
247 const result = await axios.post<{
248 code: number; // 0;
249 message: string; // '0';
250 ttl: number; // 1;
251 request_id: string; // '7b753a287405461f5afa526a1f672094';
252 data: {
253 upload_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d';
254 };
255 }>(url, body);
256 return result.data.data.upload_token;
257 }
258
259 /**
260 * 封面上传
261 * @param accessToken
262 * @param file
263 * @returns
264 */
265 async coverUpload(
266 accessToken: string,
267 file: Express.Multer.File,
268 ): Promise<string> {
269 const url = `https://member.bilibili.com/arcopen/fn/archive/cover/upload`;
270
271 const formData = new FormData();
272 const blob = new Blob([file.buffer], { type: file.mimetype });
273 formData.append('file', blob, file.originalname);
274
275 const result = await axios.post<{
276 code: number; // 0;
277 message: string; // '0';
278 ttl: number; // 1;
279 request_id: string; // '7b753a287405461f5afa526a1f672094';
280 data: {
281 url: string; // "https://archive.biliimg.com/bfs/..."
282 };
283 }>(url, formData, {
284 headers: this.generateBilibiliHeader({
285 accessToken: accessToken,
286 isForm: true,
287 }),
288 });
289 return result.data.data.url;
290 }
291
292 /**
293 * 视频稿件提交
294 * @param accessToken
295 * @param uploadToken
296 * @param data
297 * @returns
298 */
299 async archiveAddByUtoken(
300 accessToken: string,
301 uploadToken: string,
302 data: {
303 title: string; // 标题
304 cover?: string; // 封面url
305 tid: number; // 分区ID,由获取分区信息接口得到
306 no_reprint?: 0 | 1; // 是否允许转载 0-允许,1-不允许。默认0
307 desc?: string; // 描述
308 tag: string; // 标签, 多个标签用英文逗号分隔,总长度小于200
309 copyright: 1 | 2; // 1-原创,2-转载(转载时source必填)
310 source?: string; // 如果copyright为转载,则此字段表示转载来源
311 topic_id?: number; // 参加的话题ID,默认情况下不填写,需要填写和运营联系
312 },
313 ): Promise<string> {
314 const url = `https://member.bilibili.com/arcopen/fn/archive/add-by-utoken`;
315
316 const result = await axios.post<{
317 code: number; // 0;
318 message: string; // '0';
319 ttl: number; // 1;
320 data: {
321 resource_id: string; // 'BV17B4y1s7R1';
322 };
323 }>(url, data, {
324 headers: this.generateBilibiliHeader({
325 accessToken,
326 }),
327 params: {
328 upload_token: uploadToken,
329 },
330 });
331 return result.data.data.resource_id;
332 }
333
334 /**
335 * 分区查询
336 * @param accessToken
337 * @returns
338 */
339 async archiveTypeList(accessToken: string) {
340 const url = `https://member.bilibili.com/arcopen/fn/archive/type/list`;
341
342 const result = await axios.get<{
343 code: number; // 0;
344 message: string; // '0';
345 ttl: number; // 1;
346 request_id: string; // '35f4a1e0d3765a92510f919d0b6721dd';
347 data: any;
348 }>(url, {
349 headers: this.generateBilibiliHeader({
350 accessToken,
351 }),
352 });
353 return result.data.data;
354 }
355 }
356
356 lines TYPESCRIPT