koishi-plugin-douyin-local-parser 0.1.0 → 0.1.1

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
@@ -14,6 +14,7 @@
14
14
  - 解析 `https://jingxuan.douyin.com/m/video|note|slides/...`
15
15
  - 从 Koishi 卡片元素的 `data` 字段、转义 JSON、普通文本中提取抖音链接
16
16
  - 支持图片、动图、视频、原文链接和作者信息返回
17
+ - 支持视频直链发送,或先下载首个视频后以 Buffer / base64 / file URL 模式发送
17
18
  - 支持 OneBot / Red 适配器的合并转发元素
18
19
 
19
20
  ## 本地测试
@@ -39,3 +40,24 @@ plugins:
39
40
  /absolute/path/to/douyin-parser/lib:
40
41
  enabled: true
41
42
  ```
43
+
44
+ 常用视频发送配置:
45
+
46
+ ```yaml
47
+ plugins:
48
+ /absolute/path/to/douyin-parser/lib:
49
+ enabled: true
50
+ showVideo: true
51
+ downloadVideoAsFile: false
52
+ videoDownloadMode: buffer
53
+ ```
54
+
55
+ 说明:
56
+
57
+ - `downloadVideoAsFile: false`:默认行为,不下载视频,直接发送抖音视频直链。
58
+ - `downloadVideoAsFile: true`:先用 Koishi 的 `ctx.http.file()` 下载首个视频,下载失败会自动回退直链。
59
+ - `videoDownloadMode: buffer`:以二进制 Buffer 传给 `h.video()`,通常最推荐。
60
+ - `videoDownloadMode: base64`:转换成 `base64://...` 发送,适合部分 OneBot 实现。
61
+ - `videoDownloadMode: file`:写入系统临时目录并以 `file://` URL 发送,适合部分 Napcat / 本地网关场景。
62
+
63
+ 下载模式只处理每个作品的首个视频,避免多视频或动态图集一次性消耗过多带宽。
package/lib/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface Config {
17
17
  maxDescLength: number;
18
18
  descTruncateSuffix: string;
19
19
  showVideo: boolean;
20
+ downloadVideoAsFile: boolean;
21
+ videoDownloadMode: 'buffer' | 'file' | 'base64';
20
22
  showAuthor: boolean;
21
23
  showLink: boolean;
22
24
  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 = 'douyin-parser';
8
15
  const logger = new koishi_1.Logger(exports.name);
@@ -31,6 +38,12 @@ exports.Config = koishi_1.Schema.intersect([
31
38
  maxDescLength: koishi_1.Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
32
39
  descTruncateSuffix: koishi_1.Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
33
40
  showVideo: koishi_1.Schema.boolean().default(true).description('返回视频元素。'),
41
+ downloadVideoAsFile: koishi_1.Schema.boolean().default(false).description('尝试先下载首个视频再发送,缓解 QQ / OneBot 等平台直链“资源已过期”的问题。会增加带宽消耗。'),
42
+ videoDownloadMode: koishi_1.Schema.union([
43
+ koishi_1.Schema.const('buffer').description('使用二进制 Buffer 方式发送视频(推荐)'),
44
+ koishi_1.Schema.const('base64').description('使用 base64:// 段发送视频(OneBot 常用格式,buffer 失败时可尝试)'),
45
+ koishi_1.Schema.const('file').description('写入临时文件并通过 file:// URL 发送(Napcat 等特殊环境)'),
46
+ ]).default('buffer').description('下载视频后的发送方式。'),
34
47
  showAuthor: koishi_1.Schema.boolean().default(true).description('展示作者。'),
35
48
  showLink: koishi_1.Schema.boolean().default(true).description('展示原文链接。'),
36
49
  }).description('内容设置'),
@@ -69,13 +82,13 @@ function apply(ctx, config) {
69
82
  const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval));
70
83
  if (!targets.length)
71
84
  return next();
72
- handleLinks(session, targets, config).catch((error) => {
85
+ handleLinks(ctx, session, targets, config).catch((error) => {
73
86
  logger.warn(error);
74
87
  });
75
88
  return next();
76
89
  }, config.middleware);
77
90
  }
78
- async function handleLinks(session, links, config) {
91
+ async function handleLinks(ctx, session, links, config) {
79
92
  let waitTipMessageId;
80
93
  if (config.waitTip) {
81
94
  const result = await session.send(`${koishi_1.h.quote(session.messageId)}${config.waitTip}`);
@@ -86,7 +99,7 @@ async function handleLinks(session, links, config) {
86
99
  for (const link of links) {
87
100
  if (config.loggerinfo)
88
101
  logger.info(`parse ${link}`);
89
- const post = await (0, parser_1.fetchDouyinPost)(link, config);
102
+ const post = await preparePostVideo(ctx, await (0, parser_1.fetchDouyinPost)(link, config), config);
90
103
  allMessages.push(...(0, parser_1.buildDouyinMessages)(post, config, session));
91
104
  }
92
105
  if (!allMessages.length)
@@ -115,6 +128,72 @@ async function handleLinks(session, links, config) {
115
128
  }
116
129
  }
117
130
  }
131
+ async function preparePostVideo(ctx, post, config) {
132
+ if (!config.downloadVideoAsFile || !config.showVideo)
133
+ return post;
134
+ const videoUrls = [...post.dynamicImageUrls, ...post.videoUrls];
135
+ const firstVideo = videoUrls[0];
136
+ if (!firstVideo || !/^https?:\/\//i.test(firstVideo))
137
+ return post;
138
+ try {
139
+ const file = await ctx.http.file(firstVideo);
140
+ if (!file?.data) {
141
+ if (config.loggerinfo)
142
+ logger.info('download video failed: empty response data');
143
+ return post;
144
+ }
145
+ const buffer = Buffer.from(file.data);
146
+ const mimeType = file.type || file.mime || 'video/mp4';
147
+ const mode = config.videoDownloadMode || 'buffer';
148
+ if (config.loggerinfo) {
149
+ logger.info(`downloaded first video (${Math.round(buffer.length / 1024)} KB), send mode: ${mode}`);
150
+ }
151
+ const remainingDynamicUrls = post.dynamicImageUrls.slice(firstVideo === post.dynamicImageUrls[0] ? 1 : 0);
152
+ const remainingVideoUrls = firstVideo === post.dynamicImageUrls[0] ? post.videoUrls : post.videoUrls.slice(1);
153
+ if (mode === 'buffer') {
154
+ return {
155
+ ...post,
156
+ videoBuffer: buffer,
157
+ videoMimeType: mimeType,
158
+ dynamicImageUrls: remainingDynamicUrls,
159
+ videoUrls: remainingVideoUrls,
160
+ };
161
+ }
162
+ const replacement = mode === 'file'
163
+ ? await createTempVideoFile(buffer, mimeType)
164
+ : `base64://${buffer.toString('base64')}`;
165
+ return {
166
+ ...post,
167
+ videoBuffer: undefined,
168
+ videoMimeType: mimeType,
169
+ dynamicImageUrls: firstVideo === post.dynamicImageUrls[0] ? [replacement, ...remainingDynamicUrls] : remainingDynamicUrls,
170
+ videoUrls: firstVideo === post.dynamicImageUrls[0] ? remainingVideoUrls : [replacement, ...remainingVideoUrls],
171
+ };
172
+ }
173
+ catch (error) {
174
+ if (config.loggerinfo)
175
+ logger.info(`download video failed: ${error instanceof Error ? error.message : String(error)}`);
176
+ return post;
177
+ }
178
+ }
179
+ async function createTempVideoFile(buffer, mimeType) {
180
+ const fileName = `douyin-video-${Date.now()}-${Math.random().toString(16).slice(2)}${getVideoFileExtension(mimeType)}`;
181
+ const filePath = node_path_1.default.join(node_os_1.default.tmpdir(), fileName);
182
+ await node_fs_1.promises.writeFile(filePath, buffer);
183
+ return node_url_1.default.pathToFileURL(filePath).href;
184
+ }
185
+ function getVideoFileExtension(mimeType) {
186
+ const lower = mimeType.toLowerCase();
187
+ if (lower.includes('mp4'))
188
+ return '.mp4';
189
+ if (lower.includes('webm'))
190
+ return '.webm';
191
+ if (lower.includes('ogg') || lower.includes('ogv'))
192
+ return '.ogv';
193
+ if (lower.includes('flv'))
194
+ return '.flv';
195
+ return '.mp4';
196
+ }
118
197
  function shouldProcess(recent, channelId, link, seconds) {
119
198
  if (seconds <= 0)
120
199
  return true;
package/lib/parser.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface DouyinPost {
25
25
  imageUrls: string[];
26
26
  dynamicImageUrls: string[];
27
27
  videoUrls: string[];
28
+ videoBuffer?: Buffer;
29
+ videoMimeType?: string;
28
30
  }
29
31
  interface ParsedDouyinUrl {
30
32
  url: string;
package/lib/parser.js CHANGED
@@ -93,6 +93,9 @@ function buildDouyinMessages(post, config, session) {
93
93
  }
94
94
  }
95
95
  if (config.showVideo) {
96
+ if (post.videoBuffer) {
97
+ messages.push((0, koishi_1.h)('message', attrs, koishi_1.h.video(post.videoBuffer, post.videoMimeType || 'video/mp4')));
98
+ }
96
99
  for (const videoUrl of [...post.dynamicImageUrls, ...post.videoUrls].slice(0, 1)) {
97
100
  messages.push((0, koishi_1.h)('message', attrs, koishi_1.h.video(videoUrl)));
98
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-douyin-local-parser",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Parse Douyin 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 { buildDouyinMessages, extractDouyinLinks, fetchDouyinPost } 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 { buildDouyinMessages, extractDouyinLinks, fetchDouyinPost, DouyinPost } from './parser'
3
7
 
4
8
  export const name = 'douyin-parser'
5
9
 
@@ -22,6 +26,8 @@ export interface Config {
22
26
  maxDescLength: number
23
27
  descTruncateSuffix: string
24
28
  showVideo: boolean
29
+ downloadVideoAsFile: boolean
30
+ videoDownloadMode: 'buffer' | 'file' | 'base64'
25
31
  showAuthor: boolean
26
32
  showLink: boolean
27
33
  showError: boolean
@@ -53,6 +59,12 @@ export const Config: Schema<Config> = Schema.intersect([
53
59
  maxDescLength: Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
54
60
  descTruncateSuffix: Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
55
61
  showVideo: Schema.boolean().default(true).description('返回视频元素。'),
62
+ downloadVideoAsFile: Schema.boolean().default(false).description('尝试先下载首个视频再发送,缓解 QQ / OneBot 等平台直链“资源已过期”的问题。会增加带宽消耗。'),
63
+ videoDownloadMode: Schema.union([
64
+ Schema.const('buffer').description('使用二进制 Buffer 方式发送视频(推荐)'),
65
+ Schema.const('base64').description('使用 base64:// 段发送视频(OneBot 常用格式,buffer 失败时可尝试)'),
66
+ Schema.const('file').description('写入临时文件并通过 file:// URL 发送(Napcat 等特殊环境)'),
67
+ ]).default('buffer').description('下载视频后的发送方式。'),
56
68
  showAuthor: Schema.boolean().default(true).description('展示作者。'),
57
69
  showLink: Schema.boolean().default(true).description('展示原文链接。'),
58
70
  }).description('内容设置'),
@@ -94,7 +106,7 @@ export function apply(ctx: Context, config: Config) {
94
106
  const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval))
95
107
  if (!targets.length) return next()
96
108
 
97
- handleLinks(session, targets, config).catch((error) => {
109
+ handleLinks(ctx, session, targets, config).catch((error) => {
98
110
  logger.warn(error)
99
111
  })
100
112
 
@@ -102,7 +114,7 @@ export function apply(ctx: Context, config: Config) {
102
114
  }, config.middleware)
103
115
  }
104
116
 
105
- async function handleLinks(session: any, links: string[], config: Config) {
117
+ async function handleLinks(ctx: Context, session: any, links: string[], config: Config) {
106
118
  let waitTipMessageId: string | undefined
107
119
 
108
120
  if (config.waitTip) {
@@ -115,7 +127,7 @@ async function handleLinks(session: any, links: string[], config: Config) {
115
127
 
116
128
  for (const link of links) {
117
129
  if (config.loggerinfo) logger.info(`parse ${link}`)
118
- const post = await fetchDouyinPost(link, config)
130
+ const post = await preparePostVideo(ctx, await fetchDouyinPost(link, config), config)
119
131
  allMessages.push(...buildDouyinMessages(post, config, session))
120
132
  }
121
133
 
@@ -143,6 +155,74 @@ async function handleLinks(session: any, links: string[], config: Config) {
143
155
  }
144
156
  }
145
157
 
158
+ async function preparePostVideo(ctx: Context, post: DouyinPost, config: Config): Promise<DouyinPost> {
159
+ if (!config.downloadVideoAsFile || !config.showVideo) return post
160
+
161
+ const videoUrls = [...post.dynamicImageUrls, ...post.videoUrls]
162
+ const firstVideo = videoUrls[0]
163
+ if (!firstVideo || !/^https?:\/\//i.test(firstVideo)) return post
164
+
165
+ try {
166
+ const file = await ctx.http.file(firstVideo)
167
+ if (!file?.data) {
168
+ if (config.loggerinfo) logger.info('download video failed: empty response data')
169
+ return post
170
+ }
171
+
172
+ const buffer = Buffer.from(file.data)
173
+ const mimeType = (file as any).type || (file as any).mime || 'video/mp4'
174
+ const mode = config.videoDownloadMode || 'buffer'
175
+
176
+ if (config.loggerinfo) {
177
+ logger.info(`downloaded first video (${Math.round(buffer.length / 1024)} KB), send mode: ${mode}`)
178
+ }
179
+
180
+ const remainingDynamicUrls = post.dynamicImageUrls.slice(firstVideo === post.dynamicImageUrls[0] ? 1 : 0)
181
+ const remainingVideoUrls = firstVideo === post.dynamicImageUrls[0] ? post.videoUrls : post.videoUrls.slice(1)
182
+
183
+ if (mode === 'buffer') {
184
+ return {
185
+ ...post,
186
+ videoBuffer: buffer,
187
+ videoMimeType: mimeType,
188
+ dynamicImageUrls: remainingDynamicUrls,
189
+ videoUrls: remainingVideoUrls,
190
+ }
191
+ }
192
+
193
+ const replacement = mode === 'file'
194
+ ? await createTempVideoFile(buffer, mimeType)
195
+ : `base64://${buffer.toString('base64')}`
196
+
197
+ return {
198
+ ...post,
199
+ videoBuffer: undefined,
200
+ videoMimeType: mimeType,
201
+ dynamicImageUrls: firstVideo === post.dynamicImageUrls[0] ? [replacement, ...remainingDynamicUrls] : remainingDynamicUrls,
202
+ videoUrls: firstVideo === post.dynamicImageUrls[0] ? remainingVideoUrls : [replacement, ...remainingVideoUrls],
203
+ }
204
+ } catch (error) {
205
+ if (config.loggerinfo) logger.info(`download video failed: ${error instanceof Error ? error.message : String(error)}`)
206
+ return post
207
+ }
208
+ }
209
+
210
+ async function createTempVideoFile(buffer: Buffer, mimeType: string): Promise<string> {
211
+ const fileName = `douyin-video-${Date.now()}-${Math.random().toString(16).slice(2)}${getVideoFileExtension(mimeType)}`
212
+ const filePath = path.join(os.tmpdir(), fileName)
213
+ await fs.writeFile(filePath, buffer)
214
+ return nodeUrl.pathToFileURL(filePath).href
215
+ }
216
+
217
+ function getVideoFileExtension(mimeType: string) {
218
+ const lower = mimeType.toLowerCase()
219
+ if (lower.includes('mp4')) return '.mp4'
220
+ if (lower.includes('webm')) return '.webm'
221
+ if (lower.includes('ogg') || lower.includes('ogv')) return '.ogv'
222
+ if (lower.includes('flv')) return '.flv'
223
+ return '.mp4'
224
+ }
225
+
146
226
  function shouldProcess(recent: Map<string, number>, channelId: string, link: string, seconds: number) {
147
227
  if (seconds <= 0) return true
148
228
 
package/src/parser.ts CHANGED
@@ -27,6 +27,8 @@ export interface DouyinPost {
27
27
  imageUrls: string[]
28
28
  dynamicImageUrls: string[]
29
29
  videoUrls: string[]
30
+ videoBuffer?: Buffer
31
+ videoMimeType?: string
30
32
  }
31
33
 
32
34
  interface ParsedDouyinUrl {
@@ -132,6 +134,9 @@ export function buildDouyinMessages(post: DouyinPost, config: DouyinConfigLike,
132
134
  }
133
135
 
134
136
  if (config.showVideo) {
137
+ if (post.videoBuffer) {
138
+ messages.push(h('message', attrs, h.video(post.videoBuffer, post.videoMimeType || 'video/mp4')))
139
+ }
135
140
  for (const videoUrl of [...post.dynamicImageUrls, ...post.videoUrls].slice(0, 1)) {
136
141
  messages.push(h('message', attrs, h.video(videoUrl)))
137
142
  }