koishi-plugin-share-links-analysis 0.2.5 → 0.4.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/lib/core.d.ts CHANGED
@@ -15,3 +15,4 @@ export declare function resolveLinks(content: string): Link[];
15
15
  * @returns 处理后的链接结果,如果失败则返回 null
16
16
  */
17
17
  export declare function processLink(ctx: Context, config: PluginConfig, link: Link, session: Session): Promise<ParsedInfo | null>;
18
+ export declare function init(ctx: Context, config: PluginConfig): Promise<null>;
package/lib/core.js CHANGED
@@ -36,6 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.resolveLinks = resolveLinks;
38
38
  exports.processLink = processLink;
39
+ exports.init = init;
39
40
  const Bilibili = __importStar(require("./parsers/bilibili"));
40
41
  const Xiaohongshu = __importStar(require("./parsers/xiaohongshu"));
41
42
  // 定义所有支持的解析器
@@ -69,3 +70,11 @@ async function processLink(ctx, config, link, session) {
69
70
  }
70
71
  return null;
71
72
  }
73
+ async function init(ctx, config) {
74
+ for (const parser of parsers) {
75
+ if (typeof parser.init === 'function') {
76
+ await parser.init(ctx, config);
77
+ }
78
+ }
79
+ return null;
80
+ }
package/lib/index.d.ts CHANGED
@@ -5,6 +5,6 @@ export declare const inject: {
5
5
  required: string[];
6
6
  optional: never[];
7
7
  };
8
- export declare const usage = "\n\u5F00\u542F\u63D2\u4EF6\u540E\uFF0C\u5373\u53EF\u81EA\u52A8\u89E3\u6790\u5206\u4EAB\u94FE\u63A5\u3002\n\u5411Bot\u53D1\u9001B\u7AD9\u3001\u5C0F\u7EA2\u4E66\u7B49\u652F\u6301\u5E73\u53F0\u7684\u5206\u4EAB\u94FE\u63A5\uFF0C\u4F1A\u8FD4\u56DE\u56FE\u6587\u4FE1\u606F\u4E0E\u89C6\u9891\u3002\n\u60A8\u53EF\u4EE5\u5728\u63D2\u4EF6\u914D\u7F6E\u4E2D\u4E3A\u4E0D\u540C\u5E73\u53F0\u5206\u522B\u8BBE\u7F6E\u8FD4\u56DE\u7684\u56FE\u6587\u6D88\u606F\u683C\u5F0F\u3002\n";
8
+ export declare const usage = "\n\u5F00\u542F\u63D2\u4EF6\u540E\uFF0C\u5373\u53EF\u81EA\u52A8\u89E3\u6790\u5206\u4EAB\u94FE\u63A5\u3002\n\u5411Bot\u53D1\u9001B\u7AD9\u3001\u5C0F\u7EA2\u4E66\u7B49\u652F\u6301\u5E73\u53F0\u7684\u5206\u4EAB\u94FE\u63A5\uFF0C\u4F1A\u8FD4\u56DE\u56FE\u6587\u4FE1\u606F\u4E0E\u89C6\u9891\u3002\n\u60A8\u53EF\u4EE5\u5728\u63D2\u4EF6\u914D\u7F6E\u4E2D\u4E3A\u4E0D\u540C\u5E73\u53F0\u5206\u522B\u8BBE\u7F6E\u8FD4\u56DE\u7684\u56FE\u6587\u6D88\u606F\u683C\u5F0F\u3002\n\u6B64\u63D2\u4EF6\u53EA\u6D4B\u8BD5\u8FC7\u5728Napcat\u4E0B\u7684\u517C\u5BB9\u6027\u60C5\u51B5\uFF0C\u4E0D\u4FDD\u8BC1\u5176\u4ED6\u5E73\u53F0\u53EF\u7528\u3002\n";
9
9
  export declare const Config: Schema<PluginConfig>;
10
10
  export declare function apply(ctx: Context, config: PluginConfig): void;
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@ exports.Config = exports.usage = exports.inject = exports.name = void 0;
5
5
  exports.apply = apply;
6
6
  const koishi_1 = require("koishi");
7
7
  const core_1 = require("./core");
8
- const xiaohongshu_1 = require("./parsers/xiaohongshu");
8
+ const utils_1 = require("./utils");
9
9
  exports.name = 'share-links-analysis';
10
10
  exports.inject = {
11
11
  required: ['BiliBiliVideo', 'database', 'puppeteer'],
@@ -15,6 +15,7 @@ exports.usage = `
15
15
  开启插件后,即可自动解析分享链接。
16
16
  向Bot发送B站、小红书等支持平台的分享链接,会返回图文信息与视频。
17
17
  您可以在插件配置中为不同平台分别设置返回的图文消息格式。
18
+ 此插件只测试过在Napcat下的兼容性情况,不保证其他平台可用。
18
19
  `;
19
20
  // 配置文件
20
21
  exports.Config = koishi_1.Schema.intersect([
@@ -23,38 +24,35 @@ exports.Config = koishi_1.Schema.intersect([
23
24
  koishi_1.Schema.const('1').description('低清晰度优先'),
24
25
  koishi_1.Schema.const('2').description('高清晰度优先'),
25
26
  ]).role('radio').default('1').description("发送的视频清晰度优先策略"),
26
- Maximumduration: koishi_1.Schema.number().default(25).description("允许解析的视频最大时长(分钟)").min(1),
27
+ Maximumduration: koishi_1.Schema.number().default(5).description("允许解析的视频最大时长(分钟)").min(1),
27
28
  Maximumduration_tip: koishi_1.Schema.string().default('视频太长啦!还是去平台官网看吧~').description("对过长视频的文字提示内容"),
28
- MinimumTimeInterval: koishi_1.Schema.number().default(180).description("若干秒内不再处理相同链接,防止刷屏").min(1),
29
+ MinimumTimeInterval: koishi_1.Schema.number().default(600).description("若干秒内不再处理相同链接,防止刷屏").min(1),
29
30
  waitTip_Switch: koishi_1.Schema.union([
30
31
  koishi_1.Schema.const(false).description('不返回文字提示'),
31
32
  koishi_1.Schema.string().description('返回文字提示'),
32
33
  ]).description("是否返回等待提示。开启后,会发送`等待提示语`").default(false),
33
- useForward: koishi_1.Schema.boolean().default(false).description("使用合并转发(强依赖Napcat,其他环境未测试)"),
34
+ useForward: koishi_1.Schema.union([
35
+ koishi_1.Schema.const("plain").description("普通发送"),
36
+ koishi_1.Schema.const("forward").description("合并转发"),
37
+ koishi_1.Schema.const("mixed").description("混合发送"),
38
+ ]).default("mixed").description("发送模式"),
34
39
  }).description("基础设置"),
35
40
  koishi_1.Schema.object({
36
41
  format: koishi_1.Schema.string().role('textarea').default(`{title}
37
42
  {cover}
38
43
  作者:{authorName}
39
- 简介:{description}
40
44
  {stats}
45
+ ----------
46
+ {description}
41
47
  {images}
42
- {video}`).description('统一主输出格式。<br/>可用占位符: `{title}`, `{cover}`, `{authorName}`, `{description}`, `{stats}`, `{sourceUrl}`, `{images}`, `{video}`, `{videoUrl}`'),
48
+ ----------
49
+ {sourceUrl}
50
+ {video}`).description('图文/视频输出格式。<br/>可用占位符: `{title}`, `{cover}`, `{authorName}`, `{description}`, `{stats}`, `{sourceUrl}`, `{images}`, `{video}`, `{videoUrl}`'),
43
51
  }).description("格式化模板"),
44
- koishi_1.Schema.object({
45
- bilibiliStatsFormat: koishi_1.Schema.string().role('textarea').default('播放: {播放} | 弹幕: {弹幕} | 点赞: {点赞} | 硬币: {硬币} | 收藏: {收藏}')
46
- .description('Bilibili 链接的数据统计格式。<br/>可用占位符: `{播放}`, `{弹幕}`, `{点赞}`, `{硬币}`, `{收藏}`'),
47
- xiaohongshuStatsFormat: koishi_1.Schema.string().role('textarea').default('点赞: {点赞} | 收藏: {收藏} | 评论: {评论}')
48
- .description('小红书链接的数据统计格式。<br/>可用占位符: `{点赞}`, `{收藏}`, `{评论}`'),
49
- }).description("数据格式化"),
50
52
  koishi_1.Schema.object({
51
53
  parseLimit: koishi_1.Schema.number().default(3).description("单对话多链接解析上限"),
52
54
  useNumeral: koishi_1.Schema.boolean().default(true).description("使用格式化数字 (如 10000 -> 1万)"),
53
55
  showError: koishi_1.Schema.boolean().default(false).description("当链接不正确时提醒发送者"),
54
- bVideoIDPreference: koishi_1.Schema.union([
55
- koishi_1.Schema.const("bv").description("BV 号"),
56
- koishi_1.Schema.const("av").description("AV 号"),
57
- ]).default("bv").description("B站ID 偏好"),
58
56
  }).description("高级解析设置"),
59
57
  koishi_1.Schema.object({
60
58
  userAgent: koishi_1.Schema.string().description("所有 API 请求所用的 User-Agent").default("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
@@ -75,13 +73,13 @@ function apply(ctx, config) {
75
73
  const logger = ctx.logger('share-links-analysis');
76
74
  const lastProcessedUrls = {};
77
75
  ctx.on('ready', async () => {
78
- logger.info('插件已启动,执行一次初始的小红书 Cookie 刷新...');
79
- await (0, xiaohongshu_1.refreshXhsCookie)(ctx, config);
76
+ logger.info('插件已启动,执行插件初始化');
77
+ await (0, core_1.init)(ctx, config);
80
78
  });
81
79
  ctx.middleware(async (session, next) => {
82
80
  if (!session.content || !session.channelId)
83
81
  return next();
84
- const content = session.content.replace(/\\/g, '');
82
+ const content = session.content.replace(/\\/g, '').replace(/&amp;/g, '&');
85
83
  const channelId = session.channelId;
86
84
  const links = (0, core_1.resolveLinks)(content);
87
85
  if (links.length === 0)
@@ -115,194 +113,20 @@ function apply(ctx, config) {
115
113
  }
116
114
  });
117
115
  }
118
- function escapeHtml(str) {
119
- if (!str)
120
- return '';
121
- return str.replace(/&/g, '&amp;')
122
- .replace(/</g, '&lt;')
123
- .replace(/>/g, '&gt;')
124
- .replace(/"/g, '&quot;')
125
- .replace(/'/g, '&#39;');
126
- }
127
116
  async function sendResult(session, config, result, logger) {
128
- if (config.useForward) {
129
- try {
130
- await sendResult_forward(session, config, result, logger);
131
- return; // 成功,结束
132
- }
133
- catch (err) {
134
- logger.warn('合并转发失败:', err);
135
- return;
136
- }
137
- }
138
- else {
139
- await sendResult_plain(session, config, result, logger);
117
+ if (!session.channel) {
118
+ await (0, utils_1.sendResult_plain)(session, config, result, logger);
140
119
  return;
141
120
  }
142
- }
143
- async function sendResult_plain(session, config, result, logger) {
144
- if (config.logLevel === 'full') {
145
- logger.info('进入普通消息发送');
146
- }
147
- let message = config.format;
148
- // 对所有文本内容进行 HTML 转义
149
- message = message.replace(/{title}/g, escapeHtml(result.title || ''));
150
- message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
151
- message = message.replace(/{description}/g, escapeHtml(result.description ? result.description : ''));
152
- message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
153
- message = message.replace(/{cover}/g, result.coverUrl ? koishi_1.h.image(result.coverUrl).toString() : '');
154
- const imagesText = result.images ? result.images.map(img => koishi_1.h.image(img).toString()).join('\n') : '';
155
- message = message.replace(/{images}/g, imagesText);
156
- message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
157
- // 【修复】只要 videoUrl 存在就处理,仅当 duration 明确超长时才替换为提示
158
- if (result.videoUrl) {
159
- message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
160
- // 仅当 duration 是有效数字且超长时,才显示提示
161
- if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
162
- const tip = escapeHtml(config.Maximumduration_tip || '');
163
- message = message.replace(/{video}/g, tip);
164
- }
165
- else {
166
- // 正常发送视频和链接
167
- message = message.replace(/{video}/g, koishi_1.h.video(result.videoUrl).toString());
168
- if (config.logLevel === 'link_only' || config.logLevel === 'full') {
169
- logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
170
- }
171
- }
172
- }
173
- else {
174
- // 没有视频则移除占位符
175
- message = message.replace(/{video}/g, '');
176
- message = message.replace(/{videoUrl}/g, '');
177
- }
178
- // 过滤空行,保留含有 < 的行(如图片、视频标签)
179
- const cleanMessage = message.split('\n').filter(line => line.trim() !== '' || line.includes('<')).join('\n');
180
- if (config.logLevel === 'full') {
181
- logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
182
- }
183
- if (cleanMessage) {
184
- await session.send(koishi_1.h.quote(session.messageId) + cleanMessage);
185
- }
186
- }
187
- async function sendResult_forward(session, config, result, logger) {
188
- if (config.logLevel === 'full') {
189
- logger.info('进入合并转发发送');
190
- }
191
- let message = config.format;
192
- // Step 1: 替换纯文本字段
193
- message = message.replace(/{title}/g, escapeHtml(result.title || ''));
194
- message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
195
- message = message.replace(/{description}/g, escapeHtml(result.description || ''));
196
- message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
197
- message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
198
- if (result.videoUrl) {
199
- message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
200
- }
201
- if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
202
- const tip = escapeHtml(config.Maximumduration_tip || '');
203
- message = message.replace(/{video}/g, tip);
204
- }
205
- // Step 2: 检查是否包含视频占位符
206
- const hasVideoInTemplate = message.includes('{video}');
207
- // Step 3: 构建富媒体映射
208
- const mediaMap = {};
209
- // 处理封面
210
- if (result.coverUrl) {
211
- mediaMap['{cover}'] = [{ type: 'image', data: { file: result.coverUrl } }];
212
- }
213
- else {
214
- mediaMap['{cover}'] = [];
215
- }
216
- // 处理图片列表
217
- if (result.images && result.images.length > 0) {
218
- mediaMap['{images}'] = result.images.map(img => ({ type: 'image', data: { file: img } }));
219
- }
220
- else {
221
- mediaMap['{images}'] = [];
222
- }
223
- // Step 4: 按行处理,仅过滤纯空行,并精确控制换行
224
- const lines = message.split('\n').filter(line => line.trim() !== '');
225
- const nonVideoSegments = [];
226
- for (let i = 0; i < lines.length; i++) {
227
- const line = lines[i];
228
- const isLastLine = i === lines.length - 1;
229
- // 按富媒体占位符分割
230
- const tokens = line.split(/(\{cover\}|\{images\}|\{video\})/g);
231
- // 用于存储当前行的消息段
232
- const currentLineSegments = [];
233
- let hasTextContent = false; // 新增标志:当前行是否包含纯文本
234
- for (const token of tokens) {
235
- if (token === '{cover}' || token === '{images}') {
236
- // 插入对应的消息段
237
- currentLineSegments.push(...mediaMap[token]);
238
- }
239
- else if (token === '{video}') {
240
- // 视频不放入 nonVideoSegments,跳过
241
- }
242
- else if (token.trim() !== '') {
243
- // 普通文本
244
- currentLineSegments.push({ type: 'text', data: { text: token } });
245
- hasTextContent = true; // 标记当前行有文本
246
- }
247
- // 注意:token 为空字符串时(如占位符在行首/尾),不添加任何内容
248
- }
249
- // 只有当 currentLineSegments 不为空时,才将其加入总列表
250
- if (currentLineSegments.length > 0) {
251
- nonVideoSegments.push(...currentLineSegments);
252
- }
253
- // 如果不是最后一行,且当前行非空,则添加一个换行符
254
- if (!isLastLine && hasTextContent) {
255
- nonVideoSegments.push({ type: 'text', data: { text: '\n' } });
256
- }
257
- }
258
- // Step 5: 构建转发节点
259
- const forwardNodes = [];
260
- // 非视频内容节点
261
- if (nonVideoSegments.length > 0) {
262
- forwardNodes.push({
263
- type: 'node',
264
- data: {
265
- user_id: session.selfId,
266
- nickname: '分享助手',
267
- content: nonVideoSegments
268
- }
269
- });
270
- }
271
- // 视频节点(仅当模板中有 {video} 且有有效视频时)
272
- if (hasVideoInTemplate && result.videoUrl) {
273
- if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
274
- // 超时提示已作为普通文本处理(在模板中替换为文字)
275
- }
276
- else {
277
- forwardNodes.push({
278
- type: 'node',
279
- data: {
280
- user_id: session.selfId,
281
- nickname: '分享助手',
282
- content: [
283
- { type: 'video', data: { file: result.videoUrl } },
284
- ]
285
- }
286
- });
287
- if (config.logLevel === 'link_only' || config.logLevel === 'full') {
288
- logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
289
- }
290
- }
291
- }
292
- if (forwardNodes.length === 0)
293
- return;
294
- if (config.logLevel === 'full') {
295
- logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
121
+ switch (config.useForward) {
122
+ case 'forward':
123
+ await (0, utils_1.sendResult_forward)(session, config, result, logger);
124
+ return;
125
+ case "plain":
126
+ await (0, utils_1.sendResult_plain)(session, config, result, logger);
127
+ return;
128
+ case "mixed":
129
+ await (0, utils_1.sendResult_mixed)(session, config, result, logger);
130
+ return;
296
131
  }
297
- // Step 6: 发送合并转发
298
- if (!(session.onebot && session.onebot._request))
299
- throw new Error("Onebot is not defined");
300
- await session.onebot._request('send_group_forward_msg', {
301
- group_id: session.guildId,
302
- messages: forwardNodes,
303
- news: [{ text: result.description || '-' }, { text: '点击查看详情 | Powered by furryaxw' }],
304
- prompt: result.title || '',
305
- summary: '分享解析',
306
- source: result.title || ''
307
- });
308
132
  }
@@ -4,6 +4,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.match = match;
5
5
  exports.process = process;
6
6
  const utils_1 = require("../utils");
7
+ const linkRules = [
8
+ {
9
+ pattern: /(?:https?:\/\/)?(?:www\.bilibili\.com\/video\/)(([ab]v[0-9a-zA-Z]+))/gi,
10
+ type: "video",
11
+ },
12
+ {
13
+ pattern: /(?:https?:\/\/)?(?:b23\.tv\/([0-9a-zA-Z]+))/gi,
14
+ type: "short",
15
+ },
16
+ ];
17
+ const bvPattern = /(?<![a-zA-Z0-9/])(BV[1-9A-HJ-NP-Za-km-z]{10})(?![a-zA-Z0-9])/gi;
7
18
  /**
8
19
  * 在文本中匹配B站链接 (长链/短链/纯BV号)
9
20
  * @param content 消息内容
@@ -11,36 +22,40 @@ const utils_1 = require("../utils");
11
22
  */
12
23
  function match(content) {
13
24
  const results = [];
14
- // 匹配标准长链接和短链接
15
- const linkRegex = [
16
- { pattern: /(https?:\/\/)?(www\.bilibili\.com\/video\/([ab]v[0-9a-zA-Z]+))/i, type: "video" },
17
- { pattern: /(https?:\/\/)?(b23\.tv\/[0-9a-zA-Z]+)/i, type: "short" },
18
- ];
19
- for (const rule of linkRegex) {
20
- const matches = [...content.matchAll(new RegExp(rule.pattern, 'gi'))];
21
- for (const matchArr of matches) {
22
- if (matchArr[2]) {
23
- // 强制将所有链接格式化为 https 开头
24
- const formattedUrl = `https://${matchArr[2]}`;
25
- results.push({
26
- platform: 'bilibili',
27
- type: rule.type,
28
- id: rule.type === 'video' ? matchArr[3] : matchArr[2],
29
- url: formattedUrl
30
- });
31
- }
25
+ const seen = new Set();
26
+ for (const { pattern, type } of linkRules) {
27
+ let match;
28
+ while ((match = pattern.exec(content)) !== null) {
29
+ const id = match[1];
30
+ if (!id)
31
+ continue;
32
+ const host = type === "short" ? "b23.tv" : "www.bilibili.com";
33
+ const path = type === "short" ? id : `video/${id}`;
34
+ const url = `https://${host}/${path}`;
35
+ if (seen.has(url))
36
+ continue;
37
+ seen.add(url);
38
+ results.push({
39
+ platform: 'bilibili',
40
+ type,
41
+ id,
42
+ url,
43
+ });
32
44
  }
33
45
  }
34
- // 匹配独立的 BV
35
- const bvPattern = /(?<![a-zA-Z0-9/])(BV[1-9A-HJ-NP-Za-km-z]{10})(?![a-zA-Z0-9])/gi;
36
- const bvMatches = [...content.matchAll(bvPattern)];
37
- for (const matchArr of bvMatches) {
38
- const videoId = matchArr[1];
46
+ // 匹配独立的 BV 号(不包含在链接中)
47
+ let bvMatch;
48
+ while ((bvMatch = bvPattern.exec(content)) !== null) {
49
+ const videoId = bvMatch[1];
50
+ const url = `https://www.bilibili.com/video/${videoId}`;
51
+ if (seen.has(url))
52
+ continue;
53
+ seen.add(url);
39
54
  results.push({
40
55
  platform: 'bilibili',
41
56
  type: 'video',
42
57
  id: videoId,
43
- url: `https://www.bilibili.com/video/${videoId}`
58
+ url,
44
59
  });
45
60
  }
46
61
  return results;
@@ -154,19 +169,13 @@ async function process(ctx, config, link, session) {
154
169
  catch (e) {
155
170
  logger.error(`通过BiliBiliVideo服务获取视频流失败,bvid: ${data.bvid}: ${e.message}`);
156
171
  }
157
- // 在解析器内部格式化 stats 字符串
158
- const stats = {
159
- '播放': (0, utils_1.numeral)(data.stat.view, config),
160
- '弹幕': (0, utils_1.numeral)(data.stat.danmaku, config),
161
- '点赞': (0, utils_1.numeral)(data.stat.like, config),
162
- '硬币': (0, utils_1.numeral)(data.stat.coin, config),
163
- '收藏': (0, utils_1.numeral)(data.stat.favorite, config),
164
- };
165
- let statsString = config.bilibiliStatsFormat;
166
- // 【修复】使用类型安全的方式遍历对象,防止 TypeScript 报错
167
- Object.keys(stats).forEach(key => {
168
- statsString = statsString.replace(`{${key}}`, stats[key]);
169
- });
172
+ const play = (0, utils_1.numeral)(data.stat.view, config);
173
+ const danmaku = (0, utils_1.numeral)(data.stat.danmaku, config);
174
+ const liked = (0, utils_1.numeral)(data.stat.like, config);
175
+ const coin = (0, utils_1.numeral)(data.stat.coin, config);
176
+ const favorite = (0, utils_1.numeral)(data.stat.favorite, config);
177
+ const statsString = `播放: ${play} | 弹幕: ${danmaku}
178
+ 点赞: ${liked} | 硬币: ${coin} | 收藏: ${favorite}`;
170
179
  return {
171
180
  platform: 'bilibili',
172
181
  title: data.title,
@@ -176,7 +185,7 @@ async function process(ctx, config, link, session) {
176
185
  videoUrl: videoUrl,
177
186
  duration: data.duration,
178
187
  sourceUrl: `https://www.bilibili.com/video/${data.bvid}`,
179
- stats: statsString, // 【修改】传入格式化后的字符串
188
+ stats: statsString,
180
189
  };
181
190
  }
182
191
  catch (error) {
@@ -11,7 +11,7 @@ export declare function match(content: string): Link[];
11
11
  * @param ctx - Koishi Context
12
12
  * @param config
13
13
  */
14
- export declare function refreshXhsCookie(ctx: Context, config: PluginConfig): Promise<boolean>;
14
+ export declare function init(ctx: Context, config: PluginConfig): Promise<boolean>;
15
15
  /**
16
16
  * 处理单个小红书链接
17
17
  * @param ctx Koishi Context
@@ -2,33 +2,63 @@
2
2
  // src/parsers/xiaohongshu.ts
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.match = match;
5
- exports.refreshXhsCookie = refreshXhsCookie;
5
+ exports.init = init;
6
6
  exports.process = process;
7
7
  const cheerio_1 = require("cheerio");
8
8
  const utils_1 = require("../utils");
9
+ const linkRules = [
10
+ {
11
+ pattern: /(?:https?:\/\/)?(?:www\.xiaohongshu\.com\/discovery\/item\/)([\w?=&\-.%]+)/gi,
12
+ type: "discovery",
13
+ },
14
+ {
15
+ pattern: /(?:https?:\/\/)?(?:www\.xiaohongshu\.com\/explore\/)([\w?=&\-.%]+)/gi,
16
+ type: "explore",
17
+ },
18
+ {
19
+ pattern: /(?:https?:\/\/)?(?:xhslink\.com\/(?:\w\/)?)([0-9a-zA-Z]+)/gi,
20
+ type: "short",
21
+ },
22
+ ];
9
23
  /**
10
24
  * 在文本中匹配小红书链接 (长链接或短链接)
11
25
  * @param content 消息内容
12
26
  * @returns 匹配到的链接对象数组
13
27
  */
14
28
  function match(content) {
15
- const urlRegex = /https?:\/\/(?:www\.xiaohongshu\.com\/discovery\/item\/[A-Za-z0-9]+|xhslink\.com\/[A-Za-z0-9]+)\??[^ \n\r]*/g;
16
- const matches = content.match(urlRegex);
17
- if (!matches)
18
- return [];
19
- return matches.map(url => ({
20
- platform: 'xiaohongshu',
21
- type: 'note',
22
- id: url.split('/').pop().split('?')[0],
23
- url: url
24
- }));
29
+ const results = [];
30
+ const seen = new Set();
31
+ for (const { pattern, type } of linkRules) {
32
+ let match;
33
+ while ((match = pattern.exec(content)) !== null) {
34
+ const idPart = match[1];
35
+ if (!idPart)
36
+ continue;
37
+ const cleanId = idPart.split('?')[0];
38
+ const host = type === "short" ? "xhslink.com" : "www.xiaohongshu.com";
39
+ const pathPrefix = type === "short"
40
+ ? (idPart.startsWith('m/') ? 'm/' : '')
41
+ : (type === "discovery" ? "discovery/item/" : "explore/");
42
+ const url = `https://${host}/${pathPrefix}${idPart}`;
43
+ if (seen.has(url))
44
+ continue;
45
+ seen.add(url);
46
+ results.push({
47
+ platform: 'xiaohongshu',
48
+ type,
49
+ id: cleanId,
50
+ url,
51
+ });
52
+ }
53
+ }
54
+ return results;
25
55
  }
26
56
  /**
27
57
  * 使用 Puppeteer 刷新小红书 Cookie 并存入数据库
28
58
  * @param ctx - Koishi Context
29
59
  * @param config
30
60
  */
31
- async function refreshXhsCookie(ctx, config) {
61
+ async function init(ctx, config) {
32
62
  const logger = ctx.logger('share-links-analysis:xiaohongshu');
33
63
  const platformId = 'xiaohongshu';
34
64
  if (!ctx.puppeteer) {
@@ -226,15 +256,10 @@ async function process(ctx, config, link, session) {
226
256
  }
227
257
  });
228
258
  }
229
- const stats = {
230
- '点赞': (0, utils_1.numeral)(parseInt(noteData.interactInfo.likedCount), config),
231
- '收藏': (0, utils_1.numeral)(parseInt(noteData.interactInfo.collectedCount), config),
232
- '评论': (0, utils_1.numeral)(parseInt(noteData.interactInfo.commentCount), config),
233
- };
234
- let statsString = config.xiaohongshuStatsFormat;
235
- Object.keys(stats).forEach(key => {
236
- statsString = statsString.replace(`{${key}}`, stats[key]);
237
- });
259
+ const liked = (0, utils_1.numeral)(parseInt(noteData.interactInfo.likedCount), config);
260
+ const collected = (0, utils_1.numeral)(parseInt(noteData.interactInfo.collectedCount), config);
261
+ const comment = (0, utils_1.numeral)(parseInt(noteData.interactInfo.commentCount), config);
262
+ const statsString = `点赞: ${liked} | 收藏: ${collected} | 评论: ${comment}`;
238
263
  return {
239
264
  platform: 'xiaohongshu',
240
265
  title: noteData.title,
package/lib/types.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  export interface Link {
2
- platform: 'bilibili' | 'xiaohongshu';
2
+ platform: string;
3
3
  type: string;
4
4
  id: string;
5
5
  url: string;
6
6
  }
7
7
  export interface ParsedInfo {
8
- platform: 'bilibili' | 'xiaohongshu';
8
+ platform: string;
9
9
  title: string;
10
10
  authorName: string;
11
11
  description?: string;
@@ -22,14 +22,11 @@ export interface PluginConfig {
22
22
  Maximumduration_tip: string;
23
23
  MinimumTimeInterval: number;
24
24
  waitTip_Switch: false | string;
25
- useForward: boolean;
25
+ useForward: 'plain' | 'forward' | 'mixed';
26
26
  format: string;
27
- bilibiliStatsFormat: string;
28
- xiaohongshuStatsFormat: string;
29
27
  parseLimit: number;
30
28
  useNumeral: boolean;
31
29
  showError: boolean;
32
- bVideoIDPreference: 'bv' | 'av';
33
30
  userAgent: string;
34
31
  logLevel: 'none' | 'link_only' | 'full';
35
32
  }
@@ -72,12 +69,6 @@ declare module 'koishi' {
72
69
  BiliBiliVideo: any;
73
70
  puppeteer?: any;
74
71
  }
75
- interface Tables {
76
- sla_cookie_cache: {
77
- platform: string;
78
- cookie: string;
79
- };
80
- }
81
72
  }
82
73
  export interface XhsImageInfo {
83
74
  imageScene: string;
package/lib/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { PluginConfig } from './types';
1
+ import { ParsedInfo, PluginConfig } from './types';
2
+ import { Logger, Session } from "koishi";
2
3
  /**
3
4
  * 将数字格式化为易读的字符串(如 万、亿)
4
5
  * @param num 数字
@@ -6,3 +7,6 @@ import { PluginConfig } from './types';
6
7
  * @returns 格式化后的字符串
7
8
  */
8
9
  export declare function numeral(num: number, config: PluginConfig): string;
10
+ export declare function sendResult_plain(session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<void>;
11
+ export declare function sendResult_forward(session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<void>;
12
+ export declare function sendResult_mixed(session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<void>;
package/lib/utils.js CHANGED
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.numeral = numeral;
4
+ exports.sendResult_plain = sendResult_plain;
5
+ exports.sendResult_forward = sendResult_forward;
6
+ exports.sendResult_mixed = sendResult_mixed;
7
+ const koishi_1 = require("koishi");
4
8
  /**
5
9
  * 将数字格式化为易读的字符串(如 万、亿)
6
10
  * @param num 数字
@@ -18,3 +22,294 @@ function numeral(num, config) {
18
22
  }
19
23
  return String(num);
20
24
  }
25
+ function escapeHtml(str) {
26
+ if (!str)
27
+ return '';
28
+ return str.replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&#39;');
33
+ }
34
+ async function sendResult_plain(session, config, result, logger) {
35
+ if (config.logLevel === 'full') {
36
+ logger.info('进入普通消息发送');
37
+ }
38
+ let message = config.format;
39
+ // 对所有文本内容进行 HTML 转义
40
+ message = message.replace(/{title}/g, escapeHtml(result.title || ''));
41
+ message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
42
+ message = message.replace(/{description}/g, escapeHtml(result.description ? result.description : ''));
43
+ message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
44
+ message = message.replace(/{cover}/g, result.coverUrl ? koishi_1.h.image(result.coverUrl).toString() : '');
45
+ const imagesText = result.images ? result.images.map(img => koishi_1.h.image(img).toString()).join('\n') : '';
46
+ message = message.replace(/{images}/g, imagesText);
47
+ message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
48
+ // 只要 videoUrl 存在就处理,仅当 duration 明确超长时才替换为提示
49
+ if (result.videoUrl) {
50
+ message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
51
+ // 仅当 duration 是有效数字且超长时,才显示提示
52
+ if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
53
+ const tip = escapeHtml(config.Maximumduration_tip || '');
54
+ message = message.replace(/{video}/g, tip);
55
+ }
56
+ else {
57
+ // 正常发送视频和链接
58
+ message = message.replace(/{video}/g, koishi_1.h.video(result.videoUrl).toString());
59
+ }
60
+ if (config.logLevel === 'link_only') {
61
+ logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
62
+ }
63
+ }
64
+ else {
65
+ // 没有视频则移除占位符
66
+ message = message.replace(/{video}/g, '');
67
+ message = message.replace(/{videoUrl}/g, '');
68
+ }
69
+ // 过滤空行,保留含有 < 的行(如图片、视频标签)
70
+ const cleanMessage = message.split('\n').filter(line => line.trim() !== '' || line.includes('<')).join('\n');
71
+ if (config.logLevel === 'full') {
72
+ logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
73
+ }
74
+ if (cleanMessage) {
75
+ await session.send(koishi_1.h.quote(session.messageId) + cleanMessage);
76
+ }
77
+ }
78
+ async function sendResult_forward(session, config, result, logger) {
79
+ if (config.logLevel === 'full') {
80
+ logger.info('进入合并转发发送');
81
+ }
82
+ let message = config.format;
83
+ // Step 1: 替换纯文本字段
84
+ message = message.replace(/{title}/g, escapeHtml(result.title || ''));
85
+ message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
86
+ message = message.replace(/{description}/g, escapeHtml(result.description || ''));
87
+ message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
88
+ message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
89
+ if (result.videoUrl) {
90
+ message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
91
+ }
92
+ if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
93
+ const tip = escapeHtml(config.Maximumduration_tip || '');
94
+ message = message.replace(/{video}/g, tip);
95
+ }
96
+ // Step 2: 检查是否包含视频占位符
97
+ const hasVideoInTemplate = message.includes('{video}');
98
+ // Step 3: 构建富媒体映射
99
+ const mediaMap = {};
100
+ // 处理封面
101
+ if (result.coverUrl) {
102
+ mediaMap['{cover}'] = [{ type: 'image', data: { file: result.coverUrl } }];
103
+ }
104
+ else {
105
+ mediaMap['{cover}'] = [];
106
+ }
107
+ // 处理图片列表
108
+ if (result.images && result.images.length > 0) {
109
+ mediaMap['{images}'] = result.images.map(img => ({ type: 'image', data: { file: img } }));
110
+ }
111
+ else {
112
+ mediaMap['{images}'] = [];
113
+ }
114
+ // Step 4: 按行处理,仅过滤纯空行,并精确控制换行
115
+ const lines = message.split('\n').filter(line => line.trim() !== '');
116
+ const nonVideoSegments = [];
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const line = lines[i];
119
+ const isLastLine = i === lines.length - 1;
120
+ // 按富媒体占位符分割
121
+ const tokens = line.split(/(\{cover\}|\{images\}|\{video\})/g);
122
+ // 用于存储当前行的消息段
123
+ const currentLineSegments = [];
124
+ let hasTextContent = false; // 新增标志:当前行是否包含纯文本
125
+ for (const token of tokens) {
126
+ if (token === '{cover}' || token === '{images}') {
127
+ // 插入对应的消息段
128
+ currentLineSegments.push(...mediaMap[token]);
129
+ }
130
+ else if (token === '{video}') {
131
+ // 视频不放入 nonVideoSegments,跳过
132
+ }
133
+ else if (token.trim() !== '') {
134
+ // 普通文本
135
+ currentLineSegments.push({ type: 'text', data: { text: token } });
136
+ hasTextContent = true; // 标记当前行有文本
137
+ }
138
+ // 注意:token 为空字符串时(如占位符在行首/尾),不添加任何内容
139
+ }
140
+ // 只有当 currentLineSegments 不为空时,才将其加入总列表
141
+ if (currentLineSegments.length > 0) {
142
+ nonVideoSegments.push(...currentLineSegments);
143
+ }
144
+ // 如果不是最后一行,且当前行非空,则添加一个换行符
145
+ if (!isLastLine && hasTextContent) {
146
+ nonVideoSegments.push({ type: 'text', data: { text: '\n' } });
147
+ }
148
+ }
149
+ // Step 5: 构建转发节点
150
+ const forwardNodes = [];
151
+ // 非视频内容节点
152
+ if (nonVideoSegments.length > 0) {
153
+ forwardNodes.push({
154
+ type: 'node',
155
+ data: {
156
+ user_id: session.selfId,
157
+ nickname: '分享助手',
158
+ content: nonVideoSegments
159
+ }
160
+ });
161
+ }
162
+ // 视频节点(仅当模板中有 {video} 且有有效视频时)
163
+ if (hasVideoInTemplate && result.videoUrl) {
164
+ if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
165
+ }
166
+ else {
167
+ forwardNodes.push({
168
+ type: 'node',
169
+ data: {
170
+ user_id: session.selfId,
171
+ nickname: '分享助手',
172
+ content: [
173
+ { type: 'video', data: { file: result.videoUrl } },
174
+ ]
175
+ }
176
+ });
177
+ }
178
+ if (config.logLevel === 'link_only') {
179
+ logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
180
+ }
181
+ }
182
+ if (forwardNodes.length === 0)
183
+ return;
184
+ if (config.logLevel === 'full') {
185
+ logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
186
+ }
187
+ // Step 6: 发送合并转发
188
+ if (!(session.onebot && session.onebot._request))
189
+ throw new Error("Onebot is not defined");
190
+ await session.onebot._request('send_group_forward_msg', {
191
+ group_id: session.guildId,
192
+ messages: forwardNodes,
193
+ news: [{ text: result.description || '-' }, { text: '点击查看详情 | Powered by furryaxw' }],
194
+ prompt: result.title || '',
195
+ summary: '分享解析',
196
+ source: result.title || ''
197
+ });
198
+ }
199
+ async function sendResult_mixed(session, config, result, logger) {
200
+ if (config.logLevel === 'full') {
201
+ logger.info('进入混合转发发送');
202
+ }
203
+ let message = config.format;
204
+ // Step 1: 替换纯文本字段
205
+ message = message.replace(/{title}/g, escapeHtml(result.title || ''));
206
+ message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
207
+ message = message.replace(/{description}/g, escapeHtml(result.description || ''));
208
+ message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
209
+ message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
210
+ if (result.videoUrl) {
211
+ message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
212
+ }
213
+ if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
214
+ const tip = escapeHtml(config.Maximumduration_tip || '');
215
+ message = message.replace(/{video}/g, tip);
216
+ }
217
+ // Step 2: 检查是否包含视频占位符
218
+ const hasVideoInTemplate = message.includes('{video}');
219
+ // Step 3: 构建富媒体映射
220
+ const mediaMap = {};
221
+ // 处理封面
222
+ if (result.coverUrl) {
223
+ mediaMap['{cover}'] = [{ type: 'image', data: { file: result.coverUrl } }];
224
+ }
225
+ else {
226
+ mediaMap['{cover}'] = [];
227
+ }
228
+ // 处理图片列表
229
+ if (result.images && result.images.length > 0) {
230
+ mediaMap['{images}'] = result.images.map(img => ({ type: 'image', data: { file: img } }));
231
+ }
232
+ else {
233
+ mediaMap['{images}'] = [];
234
+ }
235
+ // Step 4: 按行处理,仅过滤纯空行,并精确控制换行
236
+ const lines = message.split('\n').filter(line => line.trim() !== '');
237
+ const nonVideoSegments = [];
238
+ for (let i = 0; i < lines.length; i++) {
239
+ const line = lines[i];
240
+ const isLastLine = i === lines.length - 1;
241
+ // 按富媒体占位符分割
242
+ const tokens = line.split(/(\{cover\}|\{images\}|\{video\})/g);
243
+ // 用于存储当前行的消息段
244
+ const currentLineSegments = [];
245
+ let hasTextContent = false; // 新增标志:当前行是否包含纯文本
246
+ for (const token of tokens) {
247
+ if (token === '{cover}' || token === '{images}') {
248
+ // 插入对应的消息段
249
+ currentLineSegments.push(...mediaMap[token]);
250
+ }
251
+ else if (token === '{video}') {
252
+ // 视频不放入 nonVideoSegments,跳过
253
+ }
254
+ else if (token.trim() !== '') {
255
+ // 普通文本
256
+ currentLineSegments.push({ type: 'text', data: { text: token } });
257
+ hasTextContent = true; // 标记当前行有文本
258
+ }
259
+ // 注意:token 为空字符串时(如占位符在行首/尾),不添加任何内容
260
+ }
261
+ // 只有当 currentLineSegments 不为空时,才将其加入总列表
262
+ if (currentLineSegments.length > 0) {
263
+ nonVideoSegments.push(...currentLineSegments);
264
+ }
265
+ // 如果不是最后一行,且当前行非空,则添加一个换行符
266
+ if (!isLastLine && hasTextContent) {
267
+ nonVideoSegments.push({ type: 'text', data: { text: '\n' } });
268
+ }
269
+ }
270
+ // Step 5: 构建转发节点
271
+ const forwardNodes = [];
272
+ // 非视频内容节点
273
+ if (nonVideoSegments.length > 0) {
274
+ forwardNodes.push({
275
+ type: 'node',
276
+ data: {
277
+ user_id: session.selfId,
278
+ nickname: '分享助手',
279
+ content: nonVideoSegments
280
+ }
281
+ });
282
+ }
283
+ let video;
284
+ // 视频节点(仅当模板中有 {video} 且有有效视频时)
285
+ if (hasVideoInTemplate && result.videoUrl) {
286
+ if (typeof result.duration === 'number' && result.duration > config.Maximumduration * 60) {
287
+ }
288
+ else {
289
+ video = koishi_1.h.video(result.videoUrl).toString();
290
+ }
291
+ if (config.logLevel === 'link_only') {
292
+ logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
293
+ }
294
+ }
295
+ if (forwardNodes.length === 0)
296
+ return;
297
+ if (config.logLevel === 'full') {
298
+ logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
299
+ }
300
+ // Step 6: 发送合并转发
301
+ if (!(session.onebot && session.onebot._request))
302
+ throw new Error("Onebot is not defined");
303
+ const promises = [];
304
+ promises.push(session.onebot._request('send_group_forward_msg', {
305
+ group_id: session.guildId,
306
+ messages: forwardNodes,
307
+ news: [{ text: result.description || '-' }, { text: '点击查看详情 | Powered by furryaxw' }],
308
+ prompt: result.title || '',
309
+ summary: '分享解析',
310
+ source: result.title || ''
311
+ }));
312
+ if (video)
313
+ promises.push(session.send(video));
314
+ await Promise.all(promises);
315
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "koishi-plugin-share-links-analysis",
3
3
  "description": "自用插件",
4
4
  "license": "MIT",
5
- "version": "0.2.5",
5
+ "version": "0.4.0",
6
6
  "main": "lib/index.js",
7
7
  "typings": "lib/index.d.ts",
8
8
  "files": [