koishi-plugin-xhs-parser 0.1.2 → 0.1.3

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 CHANGED
@@ -9,6 +9,7 @@
9
9
  - 解析 `https://xhslink.com/...`,例如 `http://xhslink.com/m/AixEkyLwpfs`
10
10
  - 从 Koishi 卡片元素的 `data` 字段、转义 JSON、普通文本中提取小红书链接
11
11
  - 支持图片、视频链接、原文链接和基础互动数据返回
12
+ - 支持视频直链发送,或先下载首个视频后以 Buffer / base64 / file URL 模式发送
12
13
  - 支持 OneBot / Red 适配器的合并转发元素
13
14
 
14
15
  ## 本地测试
@@ -33,4 +34,25 @@ npm run dev -- "http://xhslink.com/m/AixEkyLwpfs"
33
34
  plugins:
34
35
  /absolute/path/to/xhs-parser/lib:
35
36
  enabled: true
36
- ```
37
+ ```
38
+
39
+ 常用视频发送配置:
40
+
41
+ ```yaml
42
+ plugins:
43
+ /absolute/path/to/xhs-parser/lib:
44
+ enabled: true
45
+ showVideo: true
46
+ downloadVideoAsFile: false
47
+ videoDownloadMode: buffer
48
+ ```
49
+
50
+ 说明:
51
+
52
+ - `downloadVideoAsFile: false`:默认行为,不下载视频,直接发送小红书视频直链。
53
+ - `downloadVideoAsFile: true`:先用 Koishi 的 `ctx.http.file()` 下载首个视频,下载失败会自动回退直链。
54
+ - `videoDownloadMode: buffer`:以二进制 Buffer 传给 `h.video()`,通常最推荐。
55
+ - `videoDownloadMode: base64`:转换成 `base64://...` 发送,适合部分 OneBot 实现。
56
+ - `videoDownloadMode: file`:写入系统临时目录并以 `file://` URL 发送,适合部分 Napcat / 本地网关场景。
57
+
58
+ 下载模式只处理每个笔记的首个视频,避免一次消息消耗过多带宽。
package/lib/index.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface Config {
18
18
  maxDescLength: number;
19
19
  descTruncateSuffix: string;
20
20
  showVideo: boolean;
21
+ downloadVideoAsFile: boolean;
22
+ videoDownloadMode: 'buffer' | 'file' | 'base64';
21
23
  showStats: boolean;
22
24
  showLink: boolean;
23
25
  showError: boolean;
package/lib/index.js CHANGED
@@ -1,8 +1,15 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.usage = exports.Config = exports.name = void 0;
4
7
  exports.apply = apply;
5
8
  const koishi_1 = require("koishi");
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_url_1 = __importDefault(require("node:url"));
12
+ const node_fs_1 = require("node:fs");
6
13
  const parser_1 = require("./parser");
7
14
  exports.name = 'xhs-parser';
8
15
  const logger = new koishi_1.Logger(exports.name);
@@ -39,6 +46,12 @@ exports.Config = koishi_1.Schema.intersect([
39
46
  maxDescLength: koishi_1.Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
40
47
  descTruncateSuffix: koishi_1.Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
41
48
  showVideo: koishi_1.Schema.boolean().default(true).description('返回视频元素。'),
49
+ downloadVideoAsFile: koishi_1.Schema.boolean().default(false).description('尝试先下载首个视频再发送,缓解 QQ / OneBot 等平台直链“资源已过期”的问题。会增加带宽消耗。'),
50
+ videoDownloadMode: koishi_1.Schema.union([
51
+ koishi_1.Schema.const('buffer').description('使用二进制 Buffer 方式发送视频(推荐)'),
52
+ koishi_1.Schema.const('base64').description('使用 base64:// 段发送视频(OneBot 常用格式,buffer 失败时可尝试)'),
53
+ koishi_1.Schema.const('file').description('写入临时文件并通过 file:// URL 发送(Napcat 等特殊环境)'),
54
+ ]).default('buffer').description('下载视频后的发送方式。'),
42
55
  showStats: koishi_1.Schema.boolean().default(true).description('展示点赞、收藏、评论、分享数据。'),
43
56
  showLink: koishi_1.Schema.boolean().default(true).description('展示原文链接。'),
44
57
  }).description('内容设置'),
@@ -76,13 +89,13 @@ function apply(ctx, config) {
76
89
  const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval));
77
90
  if (!targets.length)
78
91
  return next();
79
- handleLinks(session, targets, config).catch((error) => {
92
+ handleLinks(ctx, session, targets, config).catch((error) => {
80
93
  logger.warn(error);
81
94
  });
82
95
  return next();
83
96
  }, config.middleware);
84
97
  }
85
- async function handleLinks(session, links, config) {
98
+ async function handleLinks(ctx, session, links, config) {
86
99
  let waitTipMessageId;
87
100
  if (config.waitTip) {
88
101
  const result = await session.send(`${koishi_1.h.quote(session.messageId)}${config.waitTip}`);
@@ -93,7 +106,7 @@ async function handleLinks(session, links, config) {
93
106
  for (const link of links) {
94
107
  if (config.loggerinfo)
95
108
  logger.info(`parse ${link}`);
96
- const note = await (0, parser_1.fetchXhsNote)(link, config);
109
+ const note = await prepareNoteVideo(ctx, await (0, parser_1.fetchXhsNote)(link, config), config);
97
110
  allMessages.push(...(0, parser_1.buildXhsMessages)(note, config, session));
98
111
  }
99
112
  if (!allMessages.length)
@@ -122,6 +135,68 @@ async function handleLinks(session, links, config) {
122
135
  }
123
136
  }
124
137
  }
138
+ async function prepareNoteVideo(ctx, note, config) {
139
+ if (!config.downloadVideoAsFile || !config.showVideo)
140
+ return note;
141
+ const firstVideo = note.videoUrls[0];
142
+ if (!firstVideo || !/^https?:\/\//i.test(firstVideo))
143
+ return note;
144
+ try {
145
+ const file = await ctx.http.file(firstVideo);
146
+ if (!file?.data) {
147
+ if (config.loggerinfo)
148
+ logger.info('download video failed: empty response data');
149
+ return note;
150
+ }
151
+ const buffer = Buffer.from(file.data);
152
+ const mimeType = file.type || file.mime || 'video/mp4';
153
+ const mode = config.videoDownloadMode || 'buffer';
154
+ if (config.loggerinfo) {
155
+ logger.info(`downloaded first video (${Math.round(buffer.length / 1024)} KB), send mode: ${mode}`);
156
+ }
157
+ const remainingVideoUrls = note.videoUrls.slice(1);
158
+ if (mode === 'buffer') {
159
+ return {
160
+ ...note,
161
+ videoBuffer: buffer,
162
+ videoMimeType: mimeType,
163
+ videoUrls: remainingVideoUrls,
164
+ };
165
+ }
166
+ const replacement = mode === 'file'
167
+ ? await createTempVideoFile(buffer, mimeType)
168
+ : `base64://${buffer.toString('base64')}`;
169
+ return {
170
+ ...note,
171
+ videoBuffer: undefined,
172
+ videoMimeType: mimeType,
173
+ videoUrls: [replacement, ...remainingVideoUrls],
174
+ };
175
+ }
176
+ catch (error) {
177
+ if (config.loggerinfo)
178
+ logger.info(`download video failed: ${error instanceof Error ? error.message : String(error)}`);
179
+ return note;
180
+ }
181
+ }
182
+ async function createTempVideoFile(buffer, mimeType) {
183
+ const fileName = `xhs-video-${Date.now()}-${Math.random().toString(16).slice(2)}${getVideoFileExtension(mimeType)}`;
184
+ const filePath = node_path_1.default.join(node_os_1.default.tmpdir(), fileName);
185
+ await node_fs_1.promises.writeFile(filePath, buffer);
186
+ return node_url_1.default.pathToFileURL(filePath).href;
187
+ }
188
+ function getVideoFileExtension(mimeType) {
189
+ const lower = mimeType.toLowerCase();
190
+ if (lower.includes('mp4'))
191
+ return '.mp4';
192
+ if (lower.includes('webm'))
193
+ return '.webm';
194
+ if (lower.includes('ogg') || lower.includes('ogv'))
195
+ return '.ogv';
196
+ if (lower.includes('flv'))
197
+ return '.flv';
198
+ return '.mp4';
199
+ }
125
200
  function shouldProcess(recent, channelId, link, seconds) {
126
201
  if (seconds <= 0)
127
202
  return true;
package/lib/parser.d.ts CHANGED
@@ -26,6 +26,8 @@ export interface XhsNote {
26
26
  shareCount?: string | number;
27
27
  imageUrls: string[];
28
28
  videoUrls: string[];
29
+ videoBuffer?: Buffer;
30
+ videoMimeType?: string;
29
31
  }
30
32
  export declare function extractXhsLinks(content: string): string[];
31
33
  export declare function resolveXhsLink(rawUrl: string, config: XhsConfigLike): Promise<string>;
package/lib/parser.js CHANGED
@@ -70,6 +70,12 @@ function buildXhsMessages(note, config, session) {
70
70
  }
71
71
  }
72
72
  if (config.showVideo) {
73
+ if (note.videoBuffer) {
74
+ messages.push((0, koishi_1.h)('message', {
75
+ userId: session?.userId,
76
+ nickname: session?.author?.nickname || session?.username,
77
+ }, koishi_1.h.video(note.videoBuffer, note.videoMimeType || 'video/mp4')));
78
+ }
73
79
  for (const videoUrl of note.videoUrls.slice(0, 1)) {
74
80
  messages.push((0, koishi_1.h)('message', {
75
81
  userId: session?.userId,
@@ -244,22 +250,16 @@ function buildNote(data, url, config) {
244
250
  videoUrls: extractVideoUrls(data),
245
251
  };
246
252
  }
247
-
248
- const mobileLine = `------------------------------------`
249
-
250
253
  function formatNoteText(note, config) {
251
254
  const lines = [
252
- `📕: ${note.title}`,
253
- `👤: ${note.authorName}`,
254
- mobileLine,
255
+ `小红书:${note.title}`,
256
+ `作者:${note.authorName}`,
255
257
  ];
256
258
  if (note.desc && config.maxDescLength > 0) {
257
259
  lines.push(trimText(note.desc, config.maxDescLength, config.descTruncateSuffix));
258
- lines.push(mobileLine);
259
260
  }
260
261
  if (config.showStats) {
261
- lines.push(`👍: ${displayCount(note.likedCount)} | 🌟: ${displayCount(note.collectedCount)} | 💬: ${displayCount(note.commentCount)} | 📤: ${displayCount(note.shareCount)}`);
262
- lines.push(mobileLine);
262
+ lines.push(`点赞:${displayCount(note.likedCount)} 收藏:${displayCount(note.collectedCount)} 评论:${displayCount(note.commentCount)} 分享:${displayCount(note.shareCount)}`);
263
263
  }
264
264
  if (config.showLink)
265
265
  lines.push(note.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-xhs-parser",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Parse Xiaohongshu links and cards for Koishi.",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { Context, Logger, Schema, h } from 'koishi'
2
- import { buildXhsMessages, extractXhsLinks, fetchXhsNote } from './parser'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import nodeUrl from 'node:url'
5
+ import { promises as fs } from 'node:fs'
6
+ import { buildXhsMessages, extractXhsLinks, fetchXhsNote, XhsNote } from './parser'
3
7
 
4
8
  export const name = 'xhs-parser'
5
9
 
@@ -23,6 +27,8 @@ export interface Config {
23
27
  maxDescLength: number
24
28
  descTruncateSuffix: string
25
29
  showVideo: boolean
30
+ downloadVideoAsFile: boolean
31
+ videoDownloadMode: 'buffer' | 'file' | 'base64'
26
32
  showStats: boolean
27
33
  showLink: boolean
28
34
  showError: boolean
@@ -62,6 +68,12 @@ export const Config: Schema<Config> = Schema.intersect([
62
68
  maxDescLength: Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
63
69
  descTruncateSuffix: Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
64
70
  showVideo: Schema.boolean().default(true).description('返回视频元素。'),
71
+ downloadVideoAsFile: Schema.boolean().default(false).description('尝试先下载首个视频再发送,缓解 QQ / OneBot 等平台直链“资源已过期”的问题。会增加带宽消耗。'),
72
+ videoDownloadMode: Schema.union([
73
+ Schema.const('buffer').description('使用二进制 Buffer 方式发送视频(推荐)'),
74
+ Schema.const('base64').description('使用 base64:// 段发送视频(OneBot 常用格式,buffer 失败时可尝试)'),
75
+ Schema.const('file').description('写入临时文件并通过 file:// URL 发送(Napcat 等特殊环境)'),
76
+ ]).default('buffer').description('下载视频后的发送方式。'),
65
77
  showStats: Schema.boolean().default(true).description('展示点赞、收藏、评论、分享数据。'),
66
78
  showLink: Schema.boolean().default(true).description('展示原文链接。'),
67
79
  }).description('内容设置'),
@@ -102,7 +114,7 @@ export function apply(ctx: Context, config: Config) {
102
114
  const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval))
103
115
  if (!targets.length) return next()
104
116
 
105
- handleLinks(session, targets, config).catch((error) => {
117
+ handleLinks(ctx, session, targets, config).catch((error) => {
106
118
  logger.warn(error)
107
119
  })
108
120
 
@@ -110,7 +122,7 @@ export function apply(ctx: Context, config: Config) {
110
122
  }, config.middleware)
111
123
  }
112
124
 
113
- async function handleLinks(session: any, links: string[], config: Config) {
125
+ async function handleLinks(ctx: Context, session: any, links: string[], config: Config) {
114
126
  let waitTipMessageId: string | undefined
115
127
 
116
128
  if (config.waitTip) {
@@ -123,7 +135,7 @@ async function handleLinks(session: any, links: string[], config: Config) {
123
135
 
124
136
  for (const link of links) {
125
137
  if (config.loggerinfo) logger.info(`parse ${link}`)
126
- const note = await fetchXhsNote(link, config)
138
+ const note = await prepareNoteVideo(ctx, await fetchXhsNote(link, config), config)
127
139
  allMessages.push(...buildXhsMessages(note, config, session))
128
140
  }
129
141
 
@@ -151,6 +163,70 @@ async function handleLinks(session: any, links: string[], config: Config) {
151
163
  }
152
164
  }
153
165
 
166
+ async function prepareNoteVideo(ctx: Context, note: XhsNote, config: Config): Promise<XhsNote> {
167
+ if (!config.downloadVideoAsFile || !config.showVideo) return note
168
+
169
+ const firstVideo = note.videoUrls[0]
170
+ if (!firstVideo || !/^https?:\/\//i.test(firstVideo)) return note
171
+
172
+ try {
173
+ const file = await ctx.http.file(firstVideo)
174
+ if (!file?.data) {
175
+ if (config.loggerinfo) logger.info('download video failed: empty response data')
176
+ return note
177
+ }
178
+
179
+ const buffer = Buffer.from(file.data)
180
+ const mimeType = (file as any).type || (file as any).mime || 'video/mp4'
181
+ const mode = config.videoDownloadMode || 'buffer'
182
+
183
+ if (config.loggerinfo) {
184
+ logger.info(`downloaded first video (${Math.round(buffer.length / 1024)} KB), send mode: ${mode}`)
185
+ }
186
+
187
+ const remainingVideoUrls = note.videoUrls.slice(1)
188
+
189
+ if (mode === 'buffer') {
190
+ return {
191
+ ...note,
192
+ videoBuffer: buffer,
193
+ videoMimeType: mimeType,
194
+ videoUrls: remainingVideoUrls,
195
+ }
196
+ }
197
+
198
+ const replacement = mode === 'file'
199
+ ? await createTempVideoFile(buffer, mimeType)
200
+ : `base64://${buffer.toString('base64')}`
201
+
202
+ return {
203
+ ...note,
204
+ videoBuffer: undefined,
205
+ videoMimeType: mimeType,
206
+ videoUrls: [replacement, ...remainingVideoUrls],
207
+ }
208
+ } catch (error) {
209
+ if (config.loggerinfo) logger.info(`download video failed: ${error instanceof Error ? error.message : String(error)}`)
210
+ return note
211
+ }
212
+ }
213
+
214
+ async function createTempVideoFile(buffer: Buffer, mimeType: string): Promise<string> {
215
+ const fileName = `xhs-video-${Date.now()}-${Math.random().toString(16).slice(2)}${getVideoFileExtension(mimeType)}`
216
+ const filePath = path.join(os.tmpdir(), fileName)
217
+ await fs.writeFile(filePath, buffer)
218
+ return nodeUrl.pathToFileURL(filePath).href
219
+ }
220
+
221
+ function getVideoFileExtension(mimeType: string) {
222
+ const lower = mimeType.toLowerCase()
223
+ if (lower.includes('mp4')) return '.mp4'
224
+ if (lower.includes('webm')) return '.webm'
225
+ if (lower.includes('ogg') || lower.includes('ogv')) return '.ogv'
226
+ if (lower.includes('flv')) return '.flv'
227
+ return '.mp4'
228
+ }
229
+
154
230
  function shouldProcess(recent: Map<string, number>, channelId: string, link: string, seconds: number) {
155
231
  if (seconds <= 0) return true
156
232
 
package/src/parser.ts CHANGED
@@ -28,6 +28,8 @@ export interface XhsNote {
28
28
  shareCount?: string | number
29
29
  imageUrls: string[]
30
30
  videoUrls: string[]
31
+ videoBuffer?: Buffer
32
+ videoMimeType?: string
31
33
  }
32
34
 
33
35
  const URL_BOUNDARY = '[^\\s"\'<>\\\\^`{|},。;!?、【】《》]+'
@@ -106,6 +108,13 @@ export function buildXhsMessages(note: XhsNote, config: XhsConfigLike, session?:
106
108
  }
107
109
 
108
110
  if (config.showVideo) {
111
+ if (note.videoBuffer) {
112
+ messages.push(h('message', {
113
+ userId: session?.userId,
114
+ nickname: session?.author?.nickname || session?.username,
115
+ }, h.video(note.videoBuffer, note.videoMimeType || 'video/mp4')))
116
+ }
117
+
109
118
  for (const videoUrl of note.videoUrls.slice(0, 1)) {
110
119
  messages.push(h('message', {
111
120
  userId: session?.userId,