koishi-plugin-xhs-parser 0.1.2 → 0.1.4

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,30 @@ 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
+ maxDownloadedVideoSizeMB: 20
49
+ maxVideoSendSizeMB: 100
50
+ ```
51
+
52
+ 说明:
53
+
54
+ - `downloadVideoAsFile: false`:默认行为,不下载视频,直接发送小红书视频直链。
55
+ - `downloadVideoAsFile: true`:先用 Koishi 的 `ctx.http.file()` 下载首个视频,下载失败会自动回退直链。
56
+ - `videoDownloadMode: buffer`:以二进制 Buffer 传给 `h.video()`,通常最推荐。
57
+ - `videoDownloadMode: base64`:转换成 `base64://...` 发送,适合部分 OneBot 实现。
58
+ - `videoDownloadMode: file`:写入系统临时目录并以 `file://` URL 发送,适合部分 Napcat / 本地网关场景。
59
+ - `maxDownloadedVideoSizeMB: 20`:下载后的视频超过该大小时,自动回退为发送小红书视频直链,避免大视频转 base64 时超过 Node 字符串长度限制。设为 `0` 表示不限制。
60
+ - `maxVideoSendSizeMB: 100`:视频超过该大小时不发送视频,包括直链。设为 `0` 表示不限制。
61
+ - `loggerinfo: true`:输出视频下载开始、下载结果、大小阈值判断、发送模式和失败原因等调试日志。
62
+
63
+ 下载模式只处理每个笔记的首个视频,避免一次消息消耗过多带宽。
package/lib/index.d.ts CHANGED
@@ -18,6 +18,10 @@ export interface Config {
18
18
  maxDescLength: number;
19
19
  descTruncateSuffix: string;
20
20
  showVideo: boolean;
21
+ downloadVideoAsFile: boolean;
22
+ videoDownloadMode: 'buffer' | 'file' | 'base64';
23
+ maxDownloadedVideoSizeMB: number;
24
+ maxVideoSendSizeMB: number;
21
25
  showStats: boolean;
22
26
  showLink: boolean;
23
27
  showError: boolean;
package/lib/index.js CHANGED
@@ -1,11 +1,19 @@
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);
16
+ const VIDEO_TOO_LARGE_MESSAGE = '[视频文件过大,跳过解析]';
9
17
  exports.Config = koishi_1.Schema.intersect([
10
18
  koishi_1.Schema.object({
11
19
  enabled: koishi_1.Schema.boolean().default(true).description('开启小红书链接/卡片解析。'),
@@ -39,6 +47,14 @@ exports.Config = koishi_1.Schema.intersect([
39
47
  maxDescLength: koishi_1.Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
40
48
  descTruncateSuffix: koishi_1.Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
41
49
  showVideo: koishi_1.Schema.boolean().default(true).description('返回视频元素。'),
50
+ downloadVideoAsFile: koishi_1.Schema.boolean().default(false).description('尝试先下载首个视频再发送,缓解 QQ / OneBot 等平台直链“资源已过期”的问题。会增加带宽消耗。'),
51
+ videoDownloadMode: koishi_1.Schema.union([
52
+ koishi_1.Schema.const('buffer').description('使用二进制 Buffer 方式发送视频(推荐)'),
53
+ koishi_1.Schema.const('base64').description('使用 base64:// 段发送视频(OneBot 常用格式,buffer 失败时可尝试)'),
54
+ koishi_1.Schema.const('file').description('写入临时文件并通过 file:// URL 发送(Napcat 等特殊环境)'),
55
+ ]).default('buffer').description('下载视频后的发送方式。'),
56
+ maxDownloadedVideoSizeMB: koishi_1.Schema.number().min(0).max(2048).step(1).default(20).description('下载视频大小上限,单位 MB。超过后自动回退为发送视频直链;设为 0 表示不限制。'),
57
+ maxVideoSendSizeMB: koishi_1.Schema.number().min(0).max(2048).step(1).default(100).description('视频发送大小上限,单位 MB。超过后不发送视频,包括直链;设为 0 表示不限制。'),
42
58
  showStats: koishi_1.Schema.boolean().default(true).description('展示点赞、收藏、评论、分享数据。'),
43
59
  showLink: koishi_1.Schema.boolean().default(true).description('展示原文链接。'),
44
60
  }).description('内容设置'),
@@ -76,13 +92,13 @@ function apply(ctx, config) {
76
92
  const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval));
77
93
  if (!targets.length)
78
94
  return next();
79
- handleLinks(session, targets, config).catch((error) => {
95
+ handleLinks(ctx, session, targets, config).catch((error) => {
80
96
  logger.warn(error);
81
97
  });
82
98
  return next();
83
99
  }, config.middleware);
84
100
  }
85
- async function handleLinks(session, links, config) {
101
+ async function handleLinks(ctx, session, links, config) {
86
102
  let waitTipMessageId;
87
103
  if (config.waitTip) {
88
104
  const result = await session.send(`${koishi_1.h.quote(session.messageId)}${config.waitTip}`);
@@ -93,7 +109,7 @@ async function handleLinks(session, links, config) {
93
109
  for (const link of links) {
94
110
  if (config.loggerinfo)
95
111
  logger.info(`parse ${link}`);
96
- const note = await (0, parser_1.fetchXhsNote)(link, config);
112
+ const note = await prepareNoteVideo(ctx, await (0, parser_1.fetchXhsNote)(link, config), config);
97
113
  allMessages.push(...(0, parser_1.buildXhsMessages)(note, config, session));
98
114
  }
99
115
  if (!allMessages.length)
@@ -122,6 +138,167 @@ async function handleLinks(session, links, config) {
122
138
  }
123
139
  }
124
140
  }
141
+ async function prepareNoteVideo(ctx, note, config) {
142
+ if (!config.showVideo) {
143
+ if (config.loggerinfo) {
144
+ logger.info('skip video: showVideo=false');
145
+ }
146
+ return note;
147
+ }
148
+ const firstVideo = note.videoUrls[0];
149
+ if (!firstVideo) {
150
+ if (config.loggerinfo)
151
+ logger.info('skip video download: no video URL found');
152
+ return note;
153
+ }
154
+ if (!/^https?:\/\//i.test(firstVideo)) {
155
+ if (config.loggerinfo)
156
+ logger.info(`skip video download: unsupported video URL protocol (${firstVideo})`);
157
+ return note;
158
+ }
159
+ const maxSendSizeBytes = getSizeLimitBytes(config.maxVideoSendSizeMB, 100);
160
+ const remoteSize = await getRemoteVideoSize(ctx, firstVideo, config);
161
+ if (remoteSize !== undefined && maxSendSizeBytes && remoteSize > maxSendSizeBytes) {
162
+ if (config.loggerinfo) {
163
+ logger.info(`skip video send: remote size=${formatBytes(remoteSize)}, max=${formatBytes(maxSendSizeBytes)}, url=${firstVideo}`);
164
+ }
165
+ return removeNoteVideos(note);
166
+ }
167
+ if (!config.downloadVideoAsFile) {
168
+ if (config.loggerinfo) {
169
+ logger.info('skip video download: downloadVideoAsFile=false, use direct URL');
170
+ }
171
+ return note;
172
+ }
173
+ try {
174
+ if (config.loggerinfo) {
175
+ logger.info(`download first video start: url=${firstVideo}`);
176
+ }
177
+ const file = await ctx.http.file(firstVideo);
178
+ if (!file?.data) {
179
+ if (config.loggerinfo)
180
+ logger.info('download video failed: empty response data');
181
+ return note;
182
+ }
183
+ const buffer = Buffer.from(file.data);
184
+ const mimeType = file.type || file.mime || 'video/mp4';
185
+ const mode = config.videoDownloadMode || 'buffer';
186
+ const maxSizeBytes = config.maxDownloadedVideoSizeMB > 0
187
+ ? config.maxDownloadedVideoSizeMB * 1024 * 1024
188
+ : 0;
189
+ if (config.loggerinfo) {
190
+ logger.info(`download first video success: size=${formatBytes(buffer.length)}, mime=${mimeType}, mode=${mode}, max=${maxSizeBytes ? formatBytes(maxSizeBytes) : 'unlimited'}`);
191
+ }
192
+ if (maxSendSizeBytes && buffer.length > maxSendSizeBytes) {
193
+ if (config.loggerinfo) {
194
+ logger.info(`skip video send: downloaded size=${formatBytes(buffer.length)}, max=${formatBytes(maxSendSizeBytes)}`);
195
+ }
196
+ return removeNoteVideos(note);
197
+ }
198
+ if (maxSizeBytes && buffer.length > maxSizeBytes) {
199
+ if (config.loggerinfo) {
200
+ logger.info(`downloaded video exceeds limit: size=${formatBytes(buffer.length)}, max=${formatBytes(maxSizeBytes)}, fallback=direct URL`);
201
+ }
202
+ return note;
203
+ }
204
+ const remainingVideoUrls = note.videoUrls.slice(1);
205
+ if (mode === 'buffer') {
206
+ if (config.loggerinfo) {
207
+ logger.info(`use downloaded video buffer: size=${formatBytes(buffer.length)}, remainingVideoUrls=${remainingVideoUrls.length}`);
208
+ }
209
+ return {
210
+ ...note,
211
+ videoBuffer: buffer,
212
+ videoMimeType: mimeType,
213
+ videoUrls: remainingVideoUrls,
214
+ };
215
+ }
216
+ const replacement = mode === 'file'
217
+ ? await createTempVideoFile(buffer, mimeType)
218
+ : `base64://${buffer.toString('base64')}`;
219
+ if (config.loggerinfo) {
220
+ logger.info(`use downloaded video ${mode}: src=${mode === 'file' ? replacement : `base64://${formatBytes(buffer.length)} raw`}, remainingVideoUrls=${remainingVideoUrls.length}`);
221
+ }
222
+ return {
223
+ ...note,
224
+ videoBuffer: undefined,
225
+ videoMimeType: mimeType,
226
+ videoUrls: [replacement, ...remainingVideoUrls],
227
+ };
228
+ }
229
+ catch (error) {
230
+ if (config.loggerinfo)
231
+ logger.info(`download video failed: ${error instanceof Error ? error.message : String(error)}`);
232
+ return note;
233
+ }
234
+ }
235
+ async function getRemoteVideoSize(ctx, url, config) {
236
+ const maxSendSizeBytes = getSizeLimitBytes(config.maxVideoSendSizeMB, 100);
237
+ if (!maxSendSizeBytes)
238
+ return undefined;
239
+ try {
240
+ if (config.loggerinfo)
241
+ logger.info(`check remote video size start: url=${url}`);
242
+ const headers = await ctx.http.head(url, { timeout: config.timeout * 1000 });
243
+ const contentLength = headers.get('content-length');
244
+ if (!contentLength) {
245
+ if (config.loggerinfo)
246
+ logger.info('check remote video size skipped: missing content-length');
247
+ return undefined;
248
+ }
249
+ const size = Number.parseInt(contentLength, 10);
250
+ if (!Number.isFinite(size) || size < 0) {
251
+ if (config.loggerinfo)
252
+ logger.info(`check remote video size skipped: invalid content-length=${contentLength}`);
253
+ return undefined;
254
+ }
255
+ if (config.loggerinfo)
256
+ logger.info(`check remote video size success: size=${formatBytes(size)}, max=${formatBytes(maxSendSizeBytes)}`);
257
+ return size;
258
+ }
259
+ catch (error) {
260
+ if (config.loggerinfo)
261
+ logger.info(`check remote video size failed: ${error instanceof Error ? error.message : String(error)}`);
262
+ return undefined;
263
+ }
264
+ }
265
+ function removeNoteVideos(note) {
266
+ return {
267
+ ...note,
268
+ videoBuffer: undefined,
269
+ videoUrls: [],
270
+ videoSkippedMessage: VIDEO_TOO_LARGE_MESSAGE,
271
+ };
272
+ }
273
+ function getSizeLimitBytes(sizeMB, fallbackMB) {
274
+ const normalized = sizeMB ?? fallbackMB;
275
+ return normalized > 0 ? normalized * 1024 * 1024 : 0;
276
+ }
277
+ function formatBytes(bytes) {
278
+ if (bytes < 1024)
279
+ return `${bytes} B`;
280
+ if (bytes < 1024 * 1024)
281
+ return `${(bytes / 1024).toFixed(1)} KB`;
282
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
283
+ }
284
+ async function createTempVideoFile(buffer, mimeType) {
285
+ const fileName = `xhs-video-${Date.now()}-${Math.random().toString(16).slice(2)}${getVideoFileExtension(mimeType)}`;
286
+ const filePath = node_path_1.default.join(node_os_1.default.tmpdir(), fileName);
287
+ await node_fs_1.promises.writeFile(filePath, buffer);
288
+ return node_url_1.default.pathToFileURL(filePath).href;
289
+ }
290
+ function getVideoFileExtension(mimeType) {
291
+ const lower = mimeType.toLowerCase();
292
+ if (lower.includes('mp4'))
293
+ return '.mp4';
294
+ if (lower.includes('webm'))
295
+ return '.webm';
296
+ if (lower.includes('ogg') || lower.includes('ogv'))
297
+ return '.ogv';
298
+ if (lower.includes('flv'))
299
+ return '.flv';
300
+ return '.mp4';
301
+ }
125
302
  function shouldProcess(recent, channelId, link, seconds) {
126
303
  if (seconds <= 0)
127
304
  return true;
package/lib/parser.d.ts CHANGED
@@ -26,6 +26,9 @@ export interface XhsNote {
26
26
  shareCount?: string | number;
27
27
  imageUrls: string[];
28
28
  videoUrls: string[];
29
+ videoBuffer?: Buffer;
30
+ videoMimeType?: string;
31
+ videoSkippedMessage?: string;
29
32
  }
30
33
  export declare function extractXhsLinks(content: string): string[];
31
34
  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,
@@ -77,6 +83,12 @@ function buildXhsMessages(note, config, session) {
77
83
  }, koishi_1.h.video(videoUrl)));
78
84
  }
79
85
  }
86
+ if (note.videoSkippedMessage) {
87
+ messages.push((0, koishi_1.h)('message', {
88
+ userId: session?.userId,
89
+ nickname: session?.author?.nickname || session?.username,
90
+ }, koishi_1.h.text(note.videoSkippedMessage)));
91
+ }
80
92
  return messages;
81
93
  }
82
94
  function extractInitialStateNote(html) {
@@ -244,22 +256,16 @@ function buildNote(data, url, config) {
244
256
  videoUrls: extractVideoUrls(data),
245
257
  };
246
258
  }
247
-
248
- const mobileLine = `------------------------------------`
249
-
250
259
  function formatNoteText(note, config) {
251
260
  const lines = [
252
- `📕: ${note.title}`,
253
- `👤: ${note.authorName}`,
254
- mobileLine,
261
+ `小红书:${note.title}`,
262
+ `作者:${note.authorName}`,
255
263
  ];
256
264
  if (note.desc && config.maxDescLength > 0) {
257
265
  lines.push(trimText(note.desc, config.maxDescLength, config.descTruncateSuffix));
258
- lines.push(mobileLine);
259
266
  }
260
267
  if (config.showStats) {
261
- lines.push(`👍: ${displayCount(note.likedCount)} | 🌟: ${displayCount(note.collectedCount)} | 💬: ${displayCount(note.commentCount)} | 📤: ${displayCount(note.shareCount)}`);
262
- lines.push(mobileLine);
268
+ lines.push(`点赞:${displayCount(note.likedCount)} 收藏:${displayCount(note.collectedCount)} 评论:${displayCount(note.commentCount)} 分享:${displayCount(note.shareCount)}`);
263
269
  }
264
270
  if (config.showLink)
265
271
  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.4",
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,9 +1,14 @@
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
 
6
10
  const logger = new Logger(name)
11
+ const VIDEO_TOO_LARGE_MESSAGE = '[视频文件过大,跳过解析]'
7
12
 
8
13
  export interface Config {
9
14
  enabled: boolean
@@ -23,6 +28,10 @@ export interface Config {
23
28
  maxDescLength: number
24
29
  descTruncateSuffix: string
25
30
  showVideo: boolean
31
+ downloadVideoAsFile: boolean
32
+ videoDownloadMode: 'buffer' | 'file' | 'base64'
33
+ maxDownloadedVideoSizeMB: number
34
+ maxVideoSendSizeMB: number
26
35
  showStats: boolean
27
36
  showLink: boolean
28
37
  showError: boolean
@@ -62,6 +71,14 @@ export const Config: Schema<Config> = Schema.intersect([
62
71
  maxDescLength: Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
63
72
  descTruncateSuffix: Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
64
73
  showVideo: Schema.boolean().default(true).description('返回视频元素。'),
74
+ downloadVideoAsFile: Schema.boolean().default(false).description('尝试先下载首个视频再发送,缓解 QQ / OneBot 等平台直链“资源已过期”的问题。会增加带宽消耗。'),
75
+ videoDownloadMode: Schema.union([
76
+ Schema.const('buffer').description('使用二进制 Buffer 方式发送视频(推荐)'),
77
+ Schema.const('base64').description('使用 base64:// 段发送视频(OneBot 常用格式,buffer 失败时可尝试)'),
78
+ Schema.const('file').description('写入临时文件并通过 file:// URL 发送(Napcat 等特殊环境)'),
79
+ ]).default('buffer').description('下载视频后的发送方式。'),
80
+ maxDownloadedVideoSizeMB: Schema.number().min(0).max(2048).step(1).default(20).description('下载视频大小上限,单位 MB。超过后自动回退为发送视频直链;设为 0 表示不限制。'),
81
+ maxVideoSendSizeMB: Schema.number().min(0).max(2048).step(1).default(100).description('视频发送大小上限,单位 MB。超过后不发送视频,包括直链;设为 0 表示不限制。'),
65
82
  showStats: Schema.boolean().default(true).description('展示点赞、收藏、评论、分享数据。'),
66
83
  showLink: Schema.boolean().default(true).description('展示原文链接。'),
67
84
  }).description('内容设置'),
@@ -102,7 +119,7 @@ export function apply(ctx: Context, config: Config) {
102
119
  const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval))
103
120
  if (!targets.length) return next()
104
121
 
105
- handleLinks(session, targets, config).catch((error) => {
122
+ handleLinks(ctx, session, targets, config).catch((error) => {
106
123
  logger.warn(error)
107
124
  })
108
125
 
@@ -110,7 +127,7 @@ export function apply(ctx: Context, config: Config) {
110
127
  }, config.middleware)
111
128
  }
112
129
 
113
- async function handleLinks(session: any, links: string[], config: Config) {
130
+ async function handleLinks(ctx: Context, session: any, links: string[], config: Config) {
114
131
  let waitTipMessageId: string | undefined
115
132
 
116
133
  if (config.waitTip) {
@@ -123,7 +140,7 @@ async function handleLinks(session: any, links: string[], config: Config) {
123
140
 
124
141
  for (const link of links) {
125
142
  if (config.loggerinfo) logger.info(`parse ${link}`)
126
- const note = await fetchXhsNote(link, config)
143
+ const note = await prepareNoteVideo(ctx, await fetchXhsNote(link, config), config)
127
144
  allMessages.push(...buildXhsMessages(note, config, session))
128
145
  }
129
146
 
@@ -151,6 +168,173 @@ async function handleLinks(session: any, links: string[], config: Config) {
151
168
  }
152
169
  }
153
170
 
171
+ async function prepareNoteVideo(ctx: Context, note: XhsNote, config: Config): Promise<XhsNote> {
172
+ if (!config.showVideo) {
173
+ if (config.loggerinfo) {
174
+ logger.info('skip video: showVideo=false')
175
+ }
176
+ return note
177
+ }
178
+
179
+ const firstVideo = note.videoUrls[0]
180
+ if (!firstVideo) {
181
+ if (config.loggerinfo) logger.info('skip video download: no video URL found')
182
+ return note
183
+ }
184
+ if (!/^https?:\/\//i.test(firstVideo)) {
185
+ if (config.loggerinfo) logger.info(`skip video download: unsupported video URL protocol (${firstVideo})`)
186
+ return note
187
+ }
188
+
189
+ const maxSendSizeBytes = getSizeLimitBytes(config.maxVideoSendSizeMB, 100)
190
+ const remoteSize = await getRemoteVideoSize(ctx, firstVideo, config)
191
+ if (remoteSize !== undefined && maxSendSizeBytes && remoteSize > maxSendSizeBytes) {
192
+ if (config.loggerinfo) {
193
+ logger.info(`skip video send: remote size=${formatBytes(remoteSize)}, max=${formatBytes(maxSendSizeBytes)}, url=${firstVideo}`)
194
+ }
195
+ return removeNoteVideos(note)
196
+ }
197
+
198
+ if (!config.downloadVideoAsFile) {
199
+ if (config.loggerinfo) {
200
+ logger.info('skip video download: downloadVideoAsFile=false, use direct URL')
201
+ }
202
+ return note
203
+ }
204
+
205
+ try {
206
+ if (config.loggerinfo) {
207
+ logger.info(`download first video start: url=${firstVideo}`)
208
+ }
209
+
210
+ const file = await ctx.http.file(firstVideo)
211
+ if (!file?.data) {
212
+ if (config.loggerinfo) logger.info('download video failed: empty response data')
213
+ return note
214
+ }
215
+
216
+ const buffer = Buffer.from(file.data)
217
+ const mimeType = (file as any).type || (file as any).mime || 'video/mp4'
218
+ const mode = config.videoDownloadMode || 'buffer'
219
+ const maxSizeBytes = config.maxDownloadedVideoSizeMB > 0
220
+ ? config.maxDownloadedVideoSizeMB * 1024 * 1024
221
+ : 0
222
+
223
+ if (config.loggerinfo) {
224
+ logger.info(`download first video success: size=${formatBytes(buffer.length)}, mime=${mimeType}, mode=${mode}, max=${maxSizeBytes ? formatBytes(maxSizeBytes) : 'unlimited'}`)
225
+ }
226
+
227
+ if (maxSendSizeBytes && buffer.length > maxSendSizeBytes) {
228
+ if (config.loggerinfo) {
229
+ logger.info(`skip video send: downloaded size=${formatBytes(buffer.length)}, max=${formatBytes(maxSendSizeBytes)}`)
230
+ }
231
+ return removeNoteVideos(note)
232
+ }
233
+
234
+ if (maxSizeBytes && buffer.length > maxSizeBytes) {
235
+ if (config.loggerinfo) {
236
+ logger.info(`downloaded video exceeds limit: size=${formatBytes(buffer.length)}, max=${formatBytes(maxSizeBytes)}, fallback=direct URL`)
237
+ }
238
+ return note
239
+ }
240
+
241
+ const remainingVideoUrls = note.videoUrls.slice(1)
242
+
243
+ if (mode === 'buffer') {
244
+ if (config.loggerinfo) {
245
+ logger.info(`use downloaded video buffer: size=${formatBytes(buffer.length)}, remainingVideoUrls=${remainingVideoUrls.length}`)
246
+ }
247
+ return {
248
+ ...note,
249
+ videoBuffer: buffer,
250
+ videoMimeType: mimeType,
251
+ videoUrls: remainingVideoUrls,
252
+ }
253
+ }
254
+
255
+ const replacement = mode === 'file'
256
+ ? await createTempVideoFile(buffer, mimeType)
257
+ : `base64://${buffer.toString('base64')}`
258
+
259
+ if (config.loggerinfo) {
260
+ logger.info(`use downloaded video ${mode}: src=${mode === 'file' ? replacement : `base64://${formatBytes(buffer.length)} raw`}, remainingVideoUrls=${remainingVideoUrls.length}`)
261
+ }
262
+
263
+ return {
264
+ ...note,
265
+ videoBuffer: undefined,
266
+ videoMimeType: mimeType,
267
+ videoUrls: [replacement, ...remainingVideoUrls],
268
+ }
269
+ } catch (error) {
270
+ if (config.loggerinfo) logger.info(`download video failed: ${error instanceof Error ? error.message : String(error)}`)
271
+ return note
272
+ }
273
+ }
274
+
275
+ async function getRemoteVideoSize(ctx: Context, url: string, config: Config): Promise<number | undefined> {
276
+ const maxSendSizeBytes = getSizeLimitBytes(config.maxVideoSendSizeMB, 100)
277
+ if (!maxSendSizeBytes) return undefined
278
+
279
+ try {
280
+ if (config.loggerinfo) logger.info(`check remote video size start: url=${url}`)
281
+ const headers = await ctx.http.head(url, { timeout: config.timeout * 1000 })
282
+ const contentLength = headers.get('content-length')
283
+ if (!contentLength) {
284
+ if (config.loggerinfo) logger.info('check remote video size skipped: missing content-length')
285
+ return undefined
286
+ }
287
+
288
+ const size = Number.parseInt(contentLength, 10)
289
+ if (!Number.isFinite(size) || size < 0) {
290
+ if (config.loggerinfo) logger.info(`check remote video size skipped: invalid content-length=${contentLength}`)
291
+ return undefined
292
+ }
293
+
294
+ if (config.loggerinfo) logger.info(`check remote video size success: size=${formatBytes(size)}, max=${formatBytes(maxSendSizeBytes)}`)
295
+ return size
296
+ } catch (error) {
297
+ if (config.loggerinfo) logger.info(`check remote video size failed: ${error instanceof Error ? error.message : String(error)}`)
298
+ return undefined
299
+ }
300
+ }
301
+
302
+ function removeNoteVideos(note: XhsNote): XhsNote {
303
+ return {
304
+ ...note,
305
+ videoBuffer: undefined,
306
+ videoUrls: [],
307
+ videoSkippedMessage: VIDEO_TOO_LARGE_MESSAGE,
308
+ }
309
+ }
310
+
311
+ function getSizeLimitBytes(sizeMB: number | undefined, fallbackMB: number): number {
312
+ const normalized = sizeMB ?? fallbackMB
313
+ return normalized > 0 ? normalized * 1024 * 1024 : 0
314
+ }
315
+
316
+ function formatBytes(bytes: number): string {
317
+ if (bytes < 1024) return `${bytes} B`
318
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
319
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
320
+ }
321
+
322
+ async function createTempVideoFile(buffer: Buffer, mimeType: string): Promise<string> {
323
+ const fileName = `xhs-video-${Date.now()}-${Math.random().toString(16).slice(2)}${getVideoFileExtension(mimeType)}`
324
+ const filePath = path.join(os.tmpdir(), fileName)
325
+ await fs.writeFile(filePath, buffer)
326
+ return nodeUrl.pathToFileURL(filePath).href
327
+ }
328
+
329
+ function getVideoFileExtension(mimeType: string) {
330
+ const lower = mimeType.toLowerCase()
331
+ if (lower.includes('mp4')) return '.mp4'
332
+ if (lower.includes('webm')) return '.webm'
333
+ if (lower.includes('ogg') || lower.includes('ogv')) return '.ogv'
334
+ if (lower.includes('flv')) return '.flv'
335
+ return '.mp4'
336
+ }
337
+
154
338
  function shouldProcess(recent: Map<string, number>, channelId: string, link: string, seconds: number) {
155
339
  if (seconds <= 0) return true
156
340
 
package/src/parser.ts CHANGED
@@ -28,6 +28,9 @@ export interface XhsNote {
28
28
  shareCount?: string | number
29
29
  imageUrls: string[]
30
30
  videoUrls: string[]
31
+ videoBuffer?: Buffer
32
+ videoMimeType?: string
33
+ videoSkippedMessage?: string
31
34
  }
32
35
 
33
36
  const URL_BOUNDARY = '[^\\s"\'<>\\\\^`{|},。;!?、【】《》]+'
@@ -106,6 +109,13 @@ export function buildXhsMessages(note: XhsNote, config: XhsConfigLike, session?:
106
109
  }
107
110
 
108
111
  if (config.showVideo) {
112
+ if (note.videoBuffer) {
113
+ messages.push(h('message', {
114
+ userId: session?.userId,
115
+ nickname: session?.author?.nickname || session?.username,
116
+ }, h.video(note.videoBuffer, note.videoMimeType || 'video/mp4')))
117
+ }
118
+
109
119
  for (const videoUrl of note.videoUrls.slice(0, 1)) {
110
120
  messages.push(h('message', {
111
121
  userId: session?.userId,
@@ -114,6 +124,13 @@ export function buildXhsMessages(note: XhsNote, config: XhsConfigLike, session?:
114
124
  }
115
125
  }
116
126
 
127
+ if (note.videoSkippedMessage) {
128
+ messages.push(h('message', {
129
+ userId: session?.userId,
130
+ nickname: session?.author?.nickname || session?.username,
131
+ }, h.text(note.videoSkippedMessage)))
132
+ }
133
+
117
134
  return messages
118
135
  }
119
136