koishi-plugin-bilitester 1.0.0

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 ADDED
@@ -0,0 +1,125 @@
1
+ # Koishi Bilitester Plugin
2
+
3
+ 一个用于 Koishi 的哔哩哔哩登录和 API 调用插件,支持二维码登录和 Cookie 管理。
4
+
5
+ ## 功能特性
6
+
7
+ - 二维码登录:通过扫描二维码登录哔哩哔哩账号
8
+ - Cookie 管理:自动存储和管理登录 Cookie
9
+ - 账号信息查询:查看当前登录账号的详细信息
10
+ - 视频信息查询:获取哔哩哔哩视频的详细信息
11
+ - 动态查询:获取用户的最新动态
12
+ - 直播间信息:获取直播间状态和信息
13
+ - 账号信息刷新:更新缓存的账号信息
14
+
15
+ ## 安装
16
+
17
+ 在 Koishi 项目的根目录下执行:
18
+
19
+ ```bash
20
+ npm install koishi-plugin-bilitester
21
+ ```
22
+
23
+ ## 配置
24
+
25
+ 在 Koishi 的配置文件中添加插件:
26
+
27
+ ```yaml
28
+ plugins:
29
+ bilibili:
30
+ # 轮询扫码状态的时间间隔(毫秒),默认 2000
31
+ pollInterval: 2000
32
+ # 二维码超时时间(秒),默认 180
33
+ qrCodeTimeout: 180
34
+ ```
35
+
36
+ ## 使用方法
37
+
38
+ ### 登录哔哩哔哩
39
+
40
+ ```
41
+ bilibili login
42
+ ```
43
+
44
+ 执行后会返回一个二维码,使用哔哩哔哩 APP 扫描二维码即可登录。
45
+
46
+ ### 查看账号信息
47
+
48
+ ```
49
+ bilibili info
50
+ ```
51
+
52
+ 显示当前登录账号的详细信息,包括用户名、UID、等级、硬币、大会员状态等。
53
+
54
+ ### 刷新账号信息
55
+
56
+ ```
57
+ bilitester refresh
58
+ ```
59
+
60
+ 更新缓存的账号信息。
61
+
62
+ ### 退出登录
63
+
64
+ ```
65
+ bilitester logout
66
+ ```
67
+
68
+ 清除本地存储的登录信息。
69
+
70
+ ### 查询视频信息
71
+
72
+ ```
73
+ bilibili video <BV号>
74
+ ```
75
+
76
+ 例如:`bilibili video BV1xx411c7mD`
77
+
78
+ ### 查询动态
79
+
80
+ ```
81
+ bilibili dynamic
82
+ ```
83
+
84
+ 获取当前登录账号的最新 5 条动态。
85
+
86
+ ### 查询直播间信息
87
+
88
+ ```
89
+ bilitester live <房间号>
90
+ ```
91
+
92
+ 例如:`bilitester live 6`
93
+
94
+ ## 数据存储
95
+
96
+ 插件使用 Koishi 的数据库存储账号信息,包括:
97
+
98
+ - Cookie 信息(SESSDATA、bili_jct、DedeUserID、DedeUserID__ckMd5)
99
+ - 用户基本信息(UID、用户名、头像)
100
+ - 会员信息(会员状态、会员类型、到期时间)
101
+ - 等级和硬币信息
102
+
103
+ ## 注意事项
104
+
105
+ 1. Cookie 会过期,如果遇到登录失效的情况,请重新登录
106
+ 2. 插件会根据用户的 QQ 号(或频道 ID)分别存储账号信息
107
+ 3. 二维码有效期为 180 秒,超时需要重新获取
108
+ 4. 建议定期刷新账号信息以保持数据最新
109
+
110
+ ## 开发
111
+
112
+ ```bash
113
+ # 安装依赖
114
+ npm install
115
+
116
+ # 编译
117
+ npm run build
118
+
119
+ # 监听模式
120
+ npm run watch
121
+ ```
122
+
123
+ ## 许可证
124
+
125
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { Context, Schema } from 'koishi';
2
+ declare module 'koishi' {
3
+ interface Tables {
4
+ bilibili: BilibiliAccount;
5
+ }
6
+ }
7
+ export interface BilibiliAccount {
8
+ id: number;
9
+ userId: string;
10
+ sessdata: string;
11
+ biliJct: string;
12
+ dedeUserId: string;
13
+ dedeUserIdCkMd5: string;
14
+ mid: string;
15
+ name: string;
16
+ face: string;
17
+ vipStatus: number;
18
+ vipType: number;
19
+ vipDueDate: number;
20
+ level: number;
21
+ coins: number;
22
+ createdAt: Date;
23
+ updatedAt: Date;
24
+ }
25
+ export interface Config {
26
+ pollInterval?: number;
27
+ qrCodeTimeout?: number;
28
+ }
29
+ export declare const Config: Schema<Config>;
30
+ export declare const name = "bilitester";
31
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.name = exports.Config = void 0;
7
+ exports.apply = apply;
8
+ const koishi_1 = require("koishi");
9
+ const axios_1 = __importDefault(require("axios"));
10
+ exports.Config = koishi_1.Schema.object({
11
+ pollInterval: koishi_1.Schema.number()
12
+ .description('轮询扫码状态的时间间隔(毫秒)')
13
+ .default(2000),
14
+ qrCodeTimeout: koishi_1.Schema.number()
15
+ .description('二维码超时时间(秒)')
16
+ .default(180),
17
+ });
18
+ exports.name = 'bilitester';
19
+ const loginSessions = new Map();
20
+ function apply(ctx, config) {
21
+ const pollInterval = config.pollInterval || 2000;
22
+ const qrCodeTimeout = config.qrCodeTimeout || 180;
23
+ ctx.model.extend('bilibili', {
24
+ id: 'unsigned',
25
+ userId: 'text',
26
+ sessdata: 'text',
27
+ biliJct: 'text',
28
+ dedeUserId: 'text',
29
+ dedeUserIdCkMd5: 'text',
30
+ mid: 'text',
31
+ name: 'text',
32
+ face: 'text',
33
+ vipStatus: 'integer',
34
+ vipType: 'integer',
35
+ vipDueDate: 'integer',
36
+ level: 'integer',
37
+ coins: 'float',
38
+ createdAt: 'timestamp',
39
+ updatedAt: 'timestamp',
40
+ }, {
41
+ autoInc: false,
42
+ });
43
+ const createAxiosInstance = (cookie) => {
44
+ return axios_1.default.create({
45
+ headers: {
46
+ 'Cookie': cookie,
47
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
48
+ 'Referer': 'https://www.bilibili.com',
49
+ },
50
+ });
51
+ };
52
+ const getCookieString = (sessdata, biliJct, dedeUserId, dedeUserIdCkMd5) => {
53
+ return `SESSDATA=${sessdata}; bili_jct=${biliJct}; DedeUserID=${dedeUserId}; DedeUserID__ckMd5=${dedeUserIdCkMd5}`;
54
+ };
55
+ const pollLoginStatus = async (qrcodeKey, userId) => {
56
+ const session = loginSessions.get(qrcodeKey);
57
+ if (!session)
58
+ return;
59
+ try {
60
+ const response = await axios_1.default.get('https://passport.bilibili.com/x/passport-login/web/qrcode/poll', {
61
+ params: { qrcode_key: qrcodeKey },
62
+ headers: {
63
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
64
+ },
65
+ });
66
+ const { code, data } = response.data;
67
+ if (code === 0 && data.code === 0) {
68
+ const cookies = response.headers['set-cookie'] || [];
69
+ let sessdata = '';
70
+ let biliJct = '';
71
+ let dedeUserId = '';
72
+ let dedeUserIdCkMd5 = '';
73
+ for (const cookie of cookies) {
74
+ if (cookie.includes('SESSDATA=')) {
75
+ sessdata = cookie.match(/SESSDATA=([^;]+)/)?.[1] || '';
76
+ }
77
+ if (cookie.includes('bili_jct=')) {
78
+ biliJct = cookie.match(/bili_jct=([^;]+)/)?.[1] || '';
79
+ }
80
+ if (cookie.includes('DedeUserID=')) {
81
+ dedeUserId = cookie.match(/DedeUserID=([^;]+)/)?.[1] || '';
82
+ }
83
+ if (cookie.includes('DedeUserID__ckMd5=')) {
84
+ dedeUserIdCkMd5 = cookie.match(/DedeUserID__ckMd5=([^;]+)/)?.[1] || '';
85
+ }
86
+ }
87
+ if (sessdata && biliJct && dedeUserId) {
88
+ const cookie = getCookieString(sessdata, biliJct, dedeUserId, dedeUserIdCkMd5);
89
+ const axiosInstance = createAxiosInstance(cookie);
90
+ try {
91
+ const userResponse = await axiosInstance.get('https://api.bilibili.com/x/web-interface/nav');
92
+ if (userResponse.data.code === 0 && userResponse.data.data.isLogin) {
93
+ const userData = userResponse.data.data;
94
+ await ctx.database.create('bilibili', {
95
+ id: Date.now(),
96
+ userId: userId,
97
+ sessdata,
98
+ biliJct,
99
+ dedeUserId,
100
+ dedeUserIdCkMd5,
101
+ mid: userData.mid,
102
+ name: userData.uname,
103
+ face: userData.face,
104
+ vipStatus: userData.vipStatus,
105
+ vipType: userData.vipType,
106
+ vipDueDate: userData.vipDueDate,
107
+ level: userData.level_info.current_level,
108
+ coins: userData.money,
109
+ createdAt: new Date(),
110
+ updatedAt: new Date(),
111
+ });
112
+ if (session.timer) {
113
+ clearInterval(session.timer);
114
+ }
115
+ loginSessions.delete(qrcodeKey);
116
+ ctx.broadcast(`哔哩哔哩登录成功!\n用户: ${userData.uname}\n等级: Lv${userData.level_info.current_level}\n硬币: ${userData.money}`);
117
+ }
118
+ }
119
+ catch (error) {
120
+ console.error('获取用户信息失败:', error);
121
+ }
122
+ }
123
+ }
124
+ else if (data.code === 86038) {
125
+ if (session.timer) {
126
+ clearInterval(session.timer);
127
+ }
128
+ loginSessions.delete(qrcodeKey);
129
+ ctx.broadcast('哔哩哔哩二维码已失效,请重新获取');
130
+ }
131
+ }
132
+ catch (error) {
133
+ console.error('轮询登录状态失败:', error);
134
+ }
135
+ };
136
+ const cmd = ctx.command('bilitester', '哔哩哔哩登录和API调用');
137
+ cmd.subcommand('login', '获取哔哩哔哩登录二维码')
138
+ .action(async ({ session }) => {
139
+ if (!session) {
140
+ return '无法获取会话信息';
141
+ }
142
+ const userId = session.userId || session.guildId || 'unknown';
143
+ try {
144
+ const response = await axios_1.default.get('https://passport.bilibili.com/x/passport-login/web/qrcode/generate', {
145
+ headers: {
146
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
147
+ },
148
+ });
149
+ const { code, data } = response.data;
150
+ if (code === 0 && data) {
151
+ const qrcodeKey = data.qrcode_key;
152
+ const loginSession = {
153
+ qrcodeKey,
154
+ userId,
155
+ startTime: Date.now(),
156
+ };
157
+ loginSessions.set(qrcodeKey, loginSession);
158
+ loginSession.timer = setInterval(() => {
159
+ pollLoginStatus(qrcodeKey, userId);
160
+ }, pollInterval);
161
+ setTimeout(() => {
162
+ const session = loginSessions.get(qrcodeKey);
163
+ if (session) {
164
+ if (session.timer) {
165
+ clearInterval(session.timer);
166
+ }
167
+ loginSessions.delete(qrcodeKey);
168
+ }
169
+ }, qrCodeTimeout * 1000);
170
+ return `请使用哔哩哔哩APP扫描以下二维码登录:\n${koishi_1.h.image(data.url)}\n二维码有效期: ${qrCodeTimeout}秒`;
171
+ }
172
+ else {
173
+ return '获取二维码失败,请稍后重试';
174
+ }
175
+ }
176
+ catch (error) {
177
+ console.error('获取二维码失败:', error);
178
+ return '获取二维码失败,请稍后重试';
179
+ }
180
+ });
181
+ cmd.subcommand('logout', '退出哔哩哔哩登录')
182
+ .action(async ({ session }) => {
183
+ if (!session) {
184
+ return '无法获取会话信息';
185
+ }
186
+ const userId = session.userId || session.guildId || 'unknown';
187
+ try {
188
+ await ctx.database.remove('bilibili', { userId });
189
+ return '已成功退出哔哩哔哩登录';
190
+ }
191
+ catch (error) {
192
+ console.error('退出登录失败:', error);
193
+ return '退出登录失败,请稍后重试';
194
+ }
195
+ });
196
+ cmd.subcommand('info', '查看当前登录的哔哩哔哩账号信息')
197
+ .action(async ({ session }) => {
198
+ if (!session) {
199
+ return '无法获取会话信息';
200
+ }
201
+ const userId = session.userId || session.guildId || 'unknown';
202
+ try {
203
+ const account = await ctx.database.get('bilibili', { userId });
204
+ if (!account || account.length === 0) {
205
+ return '您还未登录哔哩哔哩账号,请使用 "bilitester login" 进行登录';
206
+ }
207
+ const acc = account[0];
208
+ const vipStatusText = acc.vipStatus === 1 ? '是' : '否';
209
+ const vipTypeText = acc.vipType === 0 ? '无' : acc.vipType === 1 ? '月度' : '年度';
210
+ return `哔哩哔哩账号信息:\n` +
211
+ `用户名: ${acc.name}\n` +
212
+ `UID: ${acc.mid}\n` +
213
+ `等级: Lv${acc.level}\n` +
214
+ `硬币: ${acc.coins}\n` +
215
+ `大会员: ${vipStatusText}\n` +
216
+ `会员类型: ${vipTypeText}\n` +
217
+ `登录时间: ${acc.createdAt.toLocaleString('zh-CN')}`;
218
+ }
219
+ catch (error) {
220
+ console.error('获取账号信息失败:', error);
221
+ return '获取账号信息失败,请稍后重试';
222
+ }
223
+ });
224
+ cmd.subcommand('refresh', '刷新账号信息')
225
+ .action(async ({ session }) => {
226
+ if (!session) {
227
+ return '无法获取会话信息';
228
+ }
229
+ const userId = session.userId || session.guildId || 'unknown';
230
+ try {
231
+ const accounts = await ctx.database.get('bilibili', { userId });
232
+ if (!accounts || accounts.length === 0) {
233
+ return '您还未登录哔哩哔哩账号,请使用 "bilitester login" 进行登录';
234
+ }
235
+ const acc = accounts[0];
236
+ const cookie = getCookieString(acc.sessdata, acc.biliJct, acc.dedeUserId, acc.dedeUserIdCkMd5);
237
+ const axiosInstance = createAxiosInstance(cookie);
238
+ const response = await axiosInstance.get('https://api.bilibili.com/x/web-interface/nav');
239
+ if (response.data.code === 0 && response.data.data.isLogin) {
240
+ const userData = response.data.data;
241
+ await ctx.database.set('bilibili', { id: acc.id }, {
242
+ mid: userData.mid,
243
+ name: userData.uname,
244
+ face: userData.face,
245
+ vipStatus: userData.vipStatus,
246
+ vipType: userData.vipType,
247
+ vipDueDate: userData.vipDueDate,
248
+ level: userData.level_info.current_level,
249
+ coins: userData.money,
250
+ updatedAt: new Date(),
251
+ });
252
+ return '账号信息已刷新成功';
253
+ }
254
+ else {
255
+ return 'Cookie已失效,请重新登录';
256
+ }
257
+ }
258
+ catch (error) {
259
+ console.error('刷新账号信息失败:', error);
260
+ return '刷新账号信息失败,请稍后重试';
261
+ }
262
+ });
263
+ cmd.subcommand('video <bvid>', '获取哔哩哔哩视频信息')
264
+ .action(async ({ session }, bvid) => {
265
+ if (!session) {
266
+ return '无法获取会话信息';
267
+ }
268
+ const userId = session.userId || session.guildId || 'unknown';
269
+ try {
270
+ const accounts = await ctx.database.get('bilibili', { userId });
271
+ if (!accounts || accounts.length === 0) {
272
+ return '您还未登录哔哩哔哩账号,请使用 "bilitester login" 进行登录';
273
+ }
274
+ const acc = accounts[0];
275
+ const cookie = getCookieString(acc.sessdata, acc.biliJct, acc.dedeUserId, acc.dedeUserIdCkMd5);
276
+ const axiosInstance = createAxiosInstance(cookie);
277
+ const response = await axiosInstance.get('https://api.bilibili.com/x/web-interface/view', {
278
+ params: { bvid }
279
+ });
280
+ if (response.data.code === 0) {
281
+ const video = response.data.data;
282
+ const duration = Math.floor(video.duration / 60) + ':' + (video.duration % 60).toString().padStart(2, '0');
283
+ const pubdate = new Date(video.pubdate * 1000).toLocaleString('zh-CN');
284
+ return `视频信息:\n` +
285
+ `标题: ${video.title}\n` +
286
+ `BV号: ${video.bvid}\n` +
287
+ `AV号: ${video.aid}\n` +
288
+ `UP主: ${video.owner.name}\n` +
289
+ `时长: ${duration}\n` +
290
+ `发布时间: ${pubdate}\n` +
291
+ `播放: ${video.stat.view} | 弹幕: ${video.stat.danmaku} | 点赞: ${video.stat.like}\n` +
292
+ `投币: ${video.stat.coin} | 收藏: ${video.stat.favorite} | 分享: ${video.stat.share}\n` +
293
+ `简介: ${video.desc.substring(0, 100)}${video.desc.length > 100 ? '...' : ''}\n` +
294
+ `${koishi_1.h.image(video.pic)}`;
295
+ }
296
+ else {
297
+ return '获取视频信息失败,请检查BV号是否正确';
298
+ }
299
+ }
300
+ catch (error) {
301
+ console.error('获取视频信息失败:', error);
302
+ return '获取视频信息失败,请稍后重试';
303
+ }
304
+ });
305
+ cmd.subcommand('dynamic', '获取哔哩哔哩动态')
306
+ .action(async ({ session }) => {
307
+ if (!session) {
308
+ return '无法获取会话信息';
309
+ }
310
+ const userId = session.userId || session.guildId || 'unknown';
311
+ try {
312
+ const accounts = await ctx.database.get('bilibili', { userId });
313
+ if (!accounts || accounts.length === 0) {
314
+ return '您还未登录哔哩哔哩账号,请使用 "bilitester login" 进行登录';
315
+ }
316
+ const acc = accounts[0];
317
+ const cookie = getCookieString(acc.sessdata, acc.biliJct, acc.dedeUserId, acc.dedeUserIdCkMd5);
318
+ const axiosInstance = createAxiosInstance(cookie);
319
+ const response = await axiosInstance.get('https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new', {
320
+ params: {
321
+ uid: acc.mid,
322
+ type_list: '8',
323
+ }
324
+ });
325
+ if (response.data.code === 0) {
326
+ const cards = response.data.data.cards.slice(0, 5);
327
+ if (cards.length === 0) {
328
+ return '暂无动态';
329
+ }
330
+ let result = '最近5条动态:\n';
331
+ for (const card of cards) {
332
+ const cardData = JSON.parse(card.card);
333
+ const time = new Date(card.desc.timestamp * 1000).toLocaleString('zh-CN');
334
+ result += `${time}\n`;
335
+ if (cardData.item) {
336
+ if (cardData.item.description) {
337
+ result += `${cardData.item.description.substring(0, 50)}${cardData.item.description.length > 50 ? '...' : ''}\n`;
338
+ }
339
+ else if (cardData.item.content) {
340
+ result += `${cardData.item.content.substring(0, 50)}${cardData.item.content.length > 50 ? '...' : ''}\n`;
341
+ }
342
+ }
343
+ result += '─'.repeat(20) + '\n';
344
+ }
345
+ return result;
346
+ }
347
+ else {
348
+ return '获取动态失败,请稍后重试';
349
+ }
350
+ }
351
+ catch (error) {
352
+ console.error('获取动态失败:', error);
353
+ return '获取动态失败,请稍后重试';
354
+ }
355
+ });
356
+ cmd.subcommand('live <roomId>', '获取哔哩哔哩直播间信息')
357
+ .action(async ({ session }, roomId) => {
358
+ if (!session) {
359
+ return '无法获取会话信息';
360
+ }
361
+ const userId = session.userId || session.guildId || 'unknown';
362
+ try {
363
+ const accounts = await ctx.database.get('bilibili', { userId });
364
+ if (!accounts || accounts.length === 0) {
365
+ return '您还未登录哔哩哔哩账号,请使用 "bilitester login" 进行登录';
366
+ }
367
+ const acc = accounts[0];
368
+ const cookie = getCookieString(acc.sessdata, acc.biliJct, acc.dedeUserId, acc.dedeUserIdCkMd5);
369
+ const axiosInstance = createAxiosInstance(cookie);
370
+ const response = await axiosInstance.get('https://api.live.bilibili.com/room/v1/Room/get_info', {
371
+ params: { room_id: roomId }
372
+ });
373
+ if (response.data.code === 0) {
374
+ const room = response.data.data;
375
+ const statusText = room.live_status === 1 ? '直播中' : '未开播';
376
+ return `直播间信息:\n` +
377
+ `房间号: ${room.roomid}\n` +
378
+ `主播UID: ${room.uid}\n` +
379
+ `标题: ${room.title}\n` +
380
+ `状态: ${statusText}\n` +
381
+ `人气: ${room.online}\n` +
382
+ `分区: ${room.parent_area_name} - ${room.area_name}\n` +
383
+ `${room.keyframe ? koishi_1.h.image(room.keyframe) : ''}`;
384
+ }
385
+ else {
386
+ return '获取直播间信息失败,请检查房间号是否正确';
387
+ }
388
+ }
389
+ catch (error) {
390
+ console.error('获取直播间信息失败:', error);
391
+ return '获取直播间信息失败,请稍后重试';
392
+ }
393
+ });
394
+ ctx.on('dispose', () => {
395
+ for (const session of loginSessions.values()) {
396
+ if (session.timer) {
397
+ clearInterval(session.timer);
398
+ }
399
+ }
400
+ loginSessions.clear();
401
+ });
402
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "koishi-plugin-bilitester",
3
+ "version": "1.0.0",
4
+ "description": "哔哩哔哩登录和API调用插件,支持二维码登录和Cookie管理",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib"
9
+ ],
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "koishi",
13
+ "plugin",
14
+ "bilibili",
15
+ "login",
16
+ "api"
17
+ ],
18
+ "dependencies": {
19
+ "axios": "^1.6.0"
20
+ },
21
+ "peerDependencies": {
22
+ "koishi": "^4.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "koishi": "^4.17.0",
26
+ "typescript": "^5.0.0"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -b",
30
+ "watch": "tsc -b -w"
31
+ },
32
+ "koishi": {
33
+ "description": {
34
+ "zh": "哔哩哔哩登录和API调用插件,支持二维码登录和Cookie管理"
35
+ }
36
+ }
37
+ }