koishi-plugin-share-links-analysis 0.4.0 → 0.5.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.js +2 -1
- package/lib/index.js +15 -10
- package/lib/parsers/bilibili.js +1 -2
- package/lib/parsers/tmp.json +46 -0
- package/lib/parsers/twitter.d.ts +10 -0
- package/lib/parsers/twitter.js +160 -0
- package/lib/parsers/xiaohongshu.js +4 -3
- package/lib/types.d.ts +8 -6
- package/lib/utils.d.ts +3 -2
- package/lib/utils.js +356 -170
- package/package.json +1 -1
package/lib/core.js
CHANGED
|
@@ -39,8 +39,9 @@ exports.processLink = processLink;
|
|
|
39
39
|
exports.init = init;
|
|
40
40
|
const Bilibili = __importStar(require("./parsers/bilibili"));
|
|
41
41
|
const Xiaohongshu = __importStar(require("./parsers/xiaohongshu"));
|
|
42
|
+
const Twitter = __importStar(require("./parsers/twitter"));
|
|
42
43
|
// 定义所有支持的解析器
|
|
43
|
-
const parsers = [Bilibili, Xiaohongshu];
|
|
44
|
+
const parsers = [Bilibili, Xiaohongshu, Twitter];
|
|
44
45
|
/**
|
|
45
46
|
* 从文本中解析出所有支持的链接
|
|
46
47
|
* @param content 消息内容
|
package/lib/index.js
CHANGED
|
@@ -24,8 +24,8 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
24
24
|
koishi_1.Schema.const('1').description('低清晰度优先'),
|
|
25
25
|
koishi_1.Schema.const('2').description('高清晰度优先'),
|
|
26
26
|
]).role('radio').default('1').description("发送的视频清晰度优先策略"),
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
Max_size: koishi_1.Schema.number().default(5).description("允许发送的最大文件大小(Mb)"),
|
|
28
|
+
Max_size_tip: koishi_1.Schema.string().default('文件体积过大,策略已阻止发送').description("对大文件的文字提示内容"),
|
|
29
29
|
MinimumTimeInterval: koishi_1.Schema.number().default(600).description("若干秒内不再处理相同链接,防止刷屏").min(1),
|
|
30
30
|
waitTip_Switch: koishi_1.Schema.union([
|
|
31
31
|
koishi_1.Schema.const(false).description('不返回文字提示'),
|
|
@@ -43,17 +43,22 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
43
43
|
作者:{authorName}
|
|
44
44
|
{stats}
|
|
45
45
|
----------
|
|
46
|
-
{
|
|
47
|
-
{images}
|
|
46
|
+
{mainbody}
|
|
48
47
|
----------
|
|
49
48
|
{sourceUrl}
|
|
50
|
-
{video}`).description('图文/视频输出格式。<br/>可用占位符: `{title}`, `{cover}`, `{authorName}`, `{
|
|
49
|
+
{video}`).description('图文/视频输出格式。<br/>可用占位符: `{title}`, `{cover}`, `{authorName}`, `{mainbody}`, `{stats}`, `{sourceUrl}`, `{video}`, `{videoUrl}`'),
|
|
51
50
|
}).description("格式化模板"),
|
|
52
51
|
koishi_1.Schema.object({
|
|
53
52
|
parseLimit: koishi_1.Schema.number().default(3).description("单对话多链接解析上限"),
|
|
54
53
|
useNumeral: koishi_1.Schema.boolean().default(true).description("使用格式化数字 (如 10000 -> 1万)"),
|
|
55
54
|
showError: koishi_1.Schema.boolean().default(false).description("当链接不正确时提醒发送者"),
|
|
55
|
+
allow_sensitive: koishi_1.Schema.boolean().default(false).description("允许NSFW内容"),
|
|
56
|
+
proxy: koishi_1.Schema.string().description("代理设置"),
|
|
56
57
|
}).description("高级解析设置"),
|
|
58
|
+
koishi_1.Schema.object({
|
|
59
|
+
onebotReadDir: koishi_1.Schema.string().description('OneBot 实现 (如 NapCat) 所在的容器或环境提供的路径前缀。').default("/app/.config/QQ/NapCat/temp"),
|
|
60
|
+
localDownloadDir: koishi_1.Schema.string().description('与上述路径对应的、Koishi 所在的容器或主机可以访问的路径前缀。').default("/koishi/data/temp"),
|
|
61
|
+
}).description('跨环境路径映射设置'),
|
|
57
62
|
koishi_1.Schema.object({
|
|
58
63
|
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"),
|
|
59
64
|
logLevel: koishi_1.Schema.union([
|
|
@@ -93,7 +98,7 @@ function apply(ctx, config) {
|
|
|
93
98
|
const now = Date.now();
|
|
94
99
|
if (!lastProcessedUrls[channelId])
|
|
95
100
|
lastProcessedUrls[channelId] = {};
|
|
96
|
-
if (now - (lastProcessedUrls[channelId][link.url] || 0) < config.
|
|
101
|
+
if (now - (lastProcessedUrls[channelId][link.url] || 0) < config.Min_Interval * 1000) {
|
|
97
102
|
if (config.logLevel === 'full')
|
|
98
103
|
logger.info(`链接 ${link.url} 在冷却时间内,跳过处理。`);
|
|
99
104
|
continue;
|
|
@@ -119,14 +124,14 @@ async function sendResult(session, config, result, logger) {
|
|
|
119
124
|
return;
|
|
120
125
|
}
|
|
121
126
|
switch (config.useForward) {
|
|
122
|
-
case 'forward':
|
|
123
|
-
await (0, utils_1.sendResult_forward)(session, config, result, logger);
|
|
124
|
-
return;
|
|
125
127
|
case "plain":
|
|
126
128
|
await (0, utils_1.sendResult_plain)(session, config, result, logger);
|
|
127
129
|
return;
|
|
130
|
+
case 'forward':
|
|
131
|
+
await (0, utils_1.sendResult_forward)(session, config, result, logger, false);
|
|
132
|
+
return;
|
|
128
133
|
case "mixed":
|
|
129
|
-
await (0, utils_1.
|
|
134
|
+
await (0, utils_1.sendResult_forward)(session, config, result, logger, true);
|
|
130
135
|
return;
|
|
131
136
|
}
|
|
132
137
|
}
|
package/lib/parsers/bilibili.js
CHANGED
|
@@ -180,10 +180,9 @@ async function process(ctx, config, link, session) {
|
|
|
180
180
|
platform: 'bilibili',
|
|
181
181
|
title: data.title,
|
|
182
182
|
authorName: data.owner.name,
|
|
183
|
-
|
|
183
|
+
mainbody: (0, utils_1.escapeHtml)(data.desc),
|
|
184
184
|
coverUrl: data.pic,
|
|
185
185
|
videoUrl: videoUrl,
|
|
186
|
-
duration: data.duration,
|
|
187
186
|
sourceUrl: `https://www.bilibili.com/video/${data.bvid}`,
|
|
188
187
|
stats: statsString,
|
|
189
188
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
allSameType: true,
|
|
3
|
+
article: null,
|
|
4
|
+
combinedMediaUrl: null,
|
|
5
|
+
communityNote: null,
|
|
6
|
+
conversationID: '1988209837298377142',
|
|
7
|
+
date: 'Tue Nov 11 11:38:59 +0000 2025',
|
|
8
|
+
date_epoch: 1762861139,
|
|
9
|
+
fetched_on: 1763038701,
|
|
10
|
+
hasMedia: true,
|
|
11
|
+
hashtags: [],
|
|
12
|
+
lang: 'zxx',
|
|
13
|
+
likes: 80,
|
|
14
|
+
mediaURLs: [
|
|
15
|
+
'https://pbs.twimg.com/media/G5eKTetbcAE9wtk.jpg'
|
|
16
|
+
],
|
|
17
|
+
media_extended: [
|
|
18
|
+
{
|
|
19
|
+
altText: null,
|
|
20
|
+
id_str: '1988209827773116417',
|
|
21
|
+
size: {
|
|
22
|
+
height: 1840,
|
|
23
|
+
width: 1840
|
|
24
|
+
},
|
|
25
|
+
thumbnail_url: 'https://pbs.twimg.com/media/G5eKTetbcAE9wtk.jpg',
|
|
26
|
+
type: 'image',
|
|
27
|
+
url: 'https://pbs.twimg.com/media/G5eKTetbcAE9wtk.jpg'
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
pollData: null,
|
|
31
|
+
possibly_sensitive: false,
|
|
32
|
+
qrt: null,
|
|
33
|
+
qrtURL: null,
|
|
34
|
+
replies: 5,
|
|
35
|
+
replyingTo: null,
|
|
36
|
+
replyingToID: null,
|
|
37
|
+
retweet: null,
|
|
38
|
+
retweetURL: null,
|
|
39
|
+
retweets: 4,
|
|
40
|
+
text: 'https://t.co/b8AyvpjhAE',
|
|
41
|
+
tweetID: '1988209837298377142',
|
|
42
|
+
tweetURL: 'https://twitter.com/moyushuang_/status/1988209837298377142',
|
|
43
|
+
user_name: '魔芋凉粉',
|
|
44
|
+
user_profile_image_url: 'https://pbs.twimg.com/profile_images/1830265587676962816/KM46JNnI_normal.jpg',
|
|
45
|
+
user_screen_name: 'moyushuang_'
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context, Session } from 'koishi';
|
|
2
|
+
import { PluginConfig, ParsedInfo, Link } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* 在文本中匹配 Twitter/X 链接
|
|
5
|
+
*/
|
|
6
|
+
export declare function match(content: string): Link[];
|
|
7
|
+
/**
|
|
8
|
+
* 处理单条 Twitter 链接
|
|
9
|
+
*/
|
|
10
|
+
export declare function process(ctx: Context, config: PluginConfig, link: Link, session: Session): Promise<ParsedInfo | null>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/parsers/twitter.ts
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.match = match;
|
|
5
|
+
exports.process = process;
|
|
6
|
+
const koishi_1 = require("koishi");
|
|
7
|
+
const utils_1 = require("../utils");
|
|
8
|
+
// ======================
|
|
9
|
+
// 链接匹配规则
|
|
10
|
+
// ======================
|
|
11
|
+
const linkRules = [
|
|
12
|
+
{
|
|
13
|
+
pattern: /https?:\/\/(?:www\.)?(?:twitter\.com|x\.com|mobile\.twitter\.com)\/([\w-]+)\/status\/(\d+)/gi,
|
|
14
|
+
type: "tweet",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
pattern: /https?:\/\/t\.co\/([a-zA-Z0-9]+)/gi,
|
|
18
|
+
type: "short",
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* 在文本中匹配 Twitter/X 链接
|
|
23
|
+
*/
|
|
24
|
+
function match(content) {
|
|
25
|
+
const results = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const rule of linkRules) {
|
|
28
|
+
let match;
|
|
29
|
+
while ((match = rule.pattern.exec(content)) !== null) {
|
|
30
|
+
const username = match[1] || 'unknown';
|
|
31
|
+
const tweetId = match[2] || match[1];
|
|
32
|
+
let cleanUrl = match[0];
|
|
33
|
+
// 处理短链接
|
|
34
|
+
if (rule.type === 'short') {
|
|
35
|
+
cleanUrl = `https://t.co/${tweetId}`;
|
|
36
|
+
// 标准化域名
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
cleanUrl = `https://x.com/${username}/status/${tweetId}`;
|
|
40
|
+
}
|
|
41
|
+
if (seen.has(cleanUrl))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(cleanUrl);
|
|
44
|
+
results.push({
|
|
45
|
+
platform: 'twitter',
|
|
46
|
+
type: rule.type,
|
|
47
|
+
id: tweetId,
|
|
48
|
+
url: cleanUrl,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 处理单条 Twitter 链接
|
|
56
|
+
*/
|
|
57
|
+
async function process(ctx, config, link, session) {
|
|
58
|
+
const logger = ctx.logger('twitter:process');
|
|
59
|
+
let apiUrl;
|
|
60
|
+
if (link.type === 'short') {
|
|
61
|
+
// 短链接需先解析,但 vxtwitter 支持直接转换
|
|
62
|
+
apiUrl = `https://api.vxtwitter.com/tweet?id=${link.id}`;
|
|
63
|
+
}
|
|
64
|
+
// 标准推文链接
|
|
65
|
+
apiUrl = link.url.replace("x.com", "api.vxtwitter.com");
|
|
66
|
+
if (!apiUrl) {
|
|
67
|
+
logger.warn(`无效的 Twitter 链接: ${link.url}`);
|
|
68
|
+
await session.send('无法解析此 Twitter 链接,URL 格式不正确');
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
logger.info(`🔍 解析推文: ${apiUrl}`);
|
|
73
|
+
const tweetData = await ctx.http.get(apiUrl, {
|
|
74
|
+
headers: {
|
|
75
|
+
'User-Agent': config.userAgent,
|
|
76
|
+
'Accept': 'application/json'
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// 处理 API 错误
|
|
80
|
+
if (tweetData?.error) {
|
|
81
|
+
logger.error(`API 错误: ${tweetData.error}`);
|
|
82
|
+
await session.send(`Twitter API 错误: ${tweetData.error}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (tweetData.possibly_sensitive && !config.allow_sensitive) {
|
|
86
|
+
await session.send('潜在的不合规内容,已停止发送');
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// 解析媒体
|
|
90
|
+
let media;
|
|
91
|
+
if (tweetData?.hasMedia) {
|
|
92
|
+
media = parseMedia(tweetData);
|
|
93
|
+
}
|
|
94
|
+
const likes = (0, utils_1.numeral)(parseInt(tweetData.likes), config);
|
|
95
|
+
const replies = (0, utils_1.numeral)(parseInt(tweetData.replies), config);
|
|
96
|
+
const retweets = (0, utils_1.numeral)(parseInt(tweetData.retweets), config);
|
|
97
|
+
const statsString = `点赞: ${likes} | 评论: ${replies} | 转发: ${retweets}`;
|
|
98
|
+
let tweet_text;
|
|
99
|
+
if (tweetData.replyingTo) {
|
|
100
|
+
tweet_text = "回复:" + tweetData.text;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
tweet_text = tweetData.text;
|
|
104
|
+
}
|
|
105
|
+
const image = media?.images ? media?.images.map(img => koishi_1.h.image(img).toString()).join('\n') : '';
|
|
106
|
+
const mainbody = (0, utils_1.escapeHtml)(tweet_text) + image;
|
|
107
|
+
return {
|
|
108
|
+
platform: 'twitter',
|
|
109
|
+
title: `@${tweetData.user_screen_name} 的推文`,
|
|
110
|
+
authorName: tweetData.user_name || tweetData.user_screen_name,
|
|
111
|
+
mainbody: mainbody,
|
|
112
|
+
sourceUrl: link.url,
|
|
113
|
+
stats: statsString,
|
|
114
|
+
videoUrl: media?.videos[0]?.url, // 取第一个视频
|
|
115
|
+
coverUrl: media?.videos[0]?.preview_url,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
logger.error(`解析失败: ${error.message || error}`);
|
|
120
|
+
// 专项错误处理
|
|
121
|
+
if (error.message?.includes('429')) {
|
|
122
|
+
await session.send('Twitter API 速率限制,请稍后再试');
|
|
123
|
+
}
|
|
124
|
+
else if (error.message?.includes('404')) {
|
|
125
|
+
await session.send('推文不存在或已删除');
|
|
126
|
+
}
|
|
127
|
+
else if (error.message?.includes('ECONNRESET') || error.message?.includes('ETIMEDOUT')) {
|
|
128
|
+
await session.send('连接 Twitter API 超时,请重试');
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
await session.send(`解析失败: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ======================
|
|
137
|
+
// 内部工具函数
|
|
138
|
+
// ======================
|
|
139
|
+
/**
|
|
140
|
+
* 解析媒体数据
|
|
141
|
+
*/
|
|
142
|
+
function parseMedia(tweetData) {
|
|
143
|
+
const images = [];
|
|
144
|
+
const videos = [];
|
|
145
|
+
for (const media of tweetData.media_extended) {
|
|
146
|
+
switch (media.type) {
|
|
147
|
+
case 'image':
|
|
148
|
+
images.push(media.url);
|
|
149
|
+
continue;
|
|
150
|
+
case 'video':
|
|
151
|
+
videos.push({
|
|
152
|
+
url: media.url,
|
|
153
|
+
preview_url: media.thumbnail_url,
|
|
154
|
+
duration: media.duration_millis ? media.duration_millis / 1000 : undefined
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { images, videos };
|
|
160
|
+
}
|
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.match = match;
|
|
5
5
|
exports.init = init;
|
|
6
6
|
exports.process = process;
|
|
7
|
+
const koishi_1 = require("koishi");
|
|
7
8
|
const cheerio_1 = require("cheerio");
|
|
8
9
|
const utils_1 = require("../utils");
|
|
9
10
|
const linkRules = [
|
|
@@ -260,17 +261,17 @@ async function process(ctx, config, link, session) {
|
|
|
260
261
|
const collected = (0, utils_1.numeral)(parseInt(noteData.interactInfo.collectedCount), config);
|
|
261
262
|
const comment = (0, utils_1.numeral)(parseInt(noteData.interactInfo.commentCount), config);
|
|
262
263
|
const statsString = `点赞: ${liked} | 收藏: ${collected} | 评论: ${comment}`;
|
|
264
|
+
const image = images ? images.map(img => koishi_1.h.image(img).toString()).join('\n') : '';
|
|
265
|
+
const mainbody = (0, utils_1.escapeHtml)(noteData.desc.trim()) + image;
|
|
263
266
|
return {
|
|
264
267
|
platform: 'xiaohongshu',
|
|
265
268
|
title: noteData.title,
|
|
266
269
|
authorName: noteData.user.nickname,
|
|
267
|
-
|
|
270
|
+
mainbody: mainbody,
|
|
268
271
|
coverUrl: coverUrl,
|
|
269
272
|
videoUrl: videoUrl,
|
|
270
|
-
duration: (videoUrl && noteData.video?.media?.duration) ? noteData.video.media.duration / 1000 : null,
|
|
271
273
|
sourceUrl: urlToFetch,
|
|
272
274
|
stats: statsString,
|
|
273
|
-
images: images.length > 0 ? images : undefined,
|
|
274
275
|
};
|
|
275
276
|
}
|
|
276
277
|
catch (error) {
|
package/lib/types.d.ts
CHANGED
|
@@ -8,25 +8,27 @@ export interface ParsedInfo {
|
|
|
8
8
|
platform: string;
|
|
9
9
|
title: string;
|
|
10
10
|
authorName: string;
|
|
11
|
-
|
|
11
|
+
mainbody?: string;
|
|
12
12
|
coverUrl?: string;
|
|
13
13
|
videoUrl?: string | null;
|
|
14
|
-
duration?: number | null;
|
|
15
14
|
sourceUrl: string;
|
|
16
15
|
stats: string;
|
|
17
|
-
images?: string[];
|
|
18
16
|
}
|
|
19
17
|
export interface PluginConfig {
|
|
20
18
|
Video_ClarityPriority: '1' | '2';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
Max_size: number;
|
|
20
|
+
Max_size_tip: string;
|
|
21
|
+
Min_Interval: number;
|
|
24
22
|
waitTip_Switch: false | string;
|
|
25
23
|
useForward: 'plain' | 'forward' | 'mixed';
|
|
26
24
|
format: string;
|
|
27
25
|
parseLimit: number;
|
|
28
26
|
useNumeral: boolean;
|
|
29
27
|
showError: boolean;
|
|
28
|
+
allow_sensitive: boolean;
|
|
29
|
+
proxy: string;
|
|
30
|
+
onebotReadDir: string;
|
|
31
|
+
localDownloadDir: string;
|
|
30
32
|
userAgent: string;
|
|
31
33
|
logLevel: 'none' | 'link_only' | 'full';
|
|
32
34
|
}
|
package/lib/utils.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Logger, Session } from "koishi";
|
|
|
7
7
|
* @returns 格式化后的字符串
|
|
8
8
|
*/
|
|
9
9
|
export declare function numeral(num: number, config: PluginConfig): string;
|
|
10
|
+
export declare function escapeHtml(str: string): string;
|
|
11
|
+
export declare function getFileSize(url: string, proxy: string | undefined, userAgent: string | undefined): Promise<number | null>;
|
|
10
12
|
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>;
|
|
13
|
+
export declare function sendResult_forward(session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, mixed_sending?: boolean): Promise<void>;
|
package/lib/utils.js
CHANGED
|
@@ -1,10 +1,55 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
2
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
39
|
exports.numeral = numeral;
|
|
40
|
+
exports.escapeHtml = escapeHtml;
|
|
41
|
+
exports.getFileSize = getFileSize;
|
|
4
42
|
exports.sendResult_plain = sendResult_plain;
|
|
5
43
|
exports.sendResult_forward = sendResult_forward;
|
|
6
|
-
exports.sendResult_mixed = sendResult_mixed;
|
|
7
44
|
const koishi_1 = require("koishi");
|
|
45
|
+
const path_1 = __importDefault(require("path"));
|
|
46
|
+
const fs_1 = require("fs");
|
|
47
|
+
const util_1 = require("util");
|
|
48
|
+
const stream_1 = require("stream");
|
|
49
|
+
const url_1 = require("url");
|
|
50
|
+
const http_proxy_agent_1 = require("http-proxy-agent");
|
|
51
|
+
const https_proxy_agent_1 = require("https-proxy-agent");
|
|
52
|
+
const fs = __importStar(require("node:fs"));
|
|
8
53
|
/**
|
|
9
54
|
* 将数字格式化为易读的字符串(如 万、亿)
|
|
10
55
|
* @param num 数字
|
|
@@ -31,42 +76,239 @@ function escapeHtml(str) {
|
|
|
31
76
|
.replace(/"/g, '"')
|
|
32
77
|
.replace(/'/g, ''');
|
|
33
78
|
}
|
|
79
|
+
function getProxyAgent(proxy, url) {
|
|
80
|
+
if (!proxy)
|
|
81
|
+
return undefined;
|
|
82
|
+
const u = new url_1.URL(url);
|
|
83
|
+
if (u.protocol === 'https:') {
|
|
84
|
+
return new https_proxy_agent_1.HttpsProxyAgent(proxy);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
return new http_proxy_agent_1.HttpProxyAgent(proxy);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function parseHtmlToSegments(html) {
|
|
91
|
+
const segments = [];
|
|
92
|
+
// 匹配 <img> 标签和普通文本
|
|
93
|
+
const tokens = html.split(/(<img\s[^>]*src\s*=\s*["']?[^"'>\s]+["']?[^>]*>)/gi);
|
|
94
|
+
for (const token of tokens) {
|
|
95
|
+
if (!token)
|
|
96
|
+
continue;
|
|
97
|
+
const imgMatch = token.match(/<img\s[^>]*src\s*=\s*["']?([^"'>\s]+)["']?/i);
|
|
98
|
+
if (imgMatch) {
|
|
99
|
+
const url = imgMatch[1];
|
|
100
|
+
segments.push({ type: 'image', data: { file: url } });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// 非 <img> 部分:当作普通文本(已转义,可直接使用)
|
|
104
|
+
// 注意:HTML 中的换行可能是 <br> 或 \n,根据实际情况处理
|
|
105
|
+
// 此处假设换行用 \n 表示(或上游已转换)
|
|
106
|
+
if (token.trim() !== '') {
|
|
107
|
+
segments.push({ type: 'text', data: { text: token } });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return segments;
|
|
112
|
+
}
|
|
113
|
+
async function downloadAndMapUrl(url, proxy, userAgent, localDownloadDir, onebotReadDir, logger) {
|
|
114
|
+
await fs.promises.mkdir(localDownloadDir, { recursive: true });
|
|
115
|
+
const u = new url_1.URL(url);
|
|
116
|
+
const ext = path_1.default.extname(u.pathname).split('?')[0] || '.bin';
|
|
117
|
+
const safeFilename = `${Date.now()}_${Math.random().toString(36).substring(2, 10)}${ext}`;
|
|
118
|
+
const actualPath = path_1.default.join(localDownloadDir, safeFilename);
|
|
119
|
+
const onebotPath = path_1.default.posix.join(onebotReadDir, safeFilename);
|
|
120
|
+
const fileUrl = `file://${onebotPath}`;
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const agent = getProxyAgent(proxy, url);
|
|
123
|
+
const headers = {
|
|
124
|
+
'User-Agent': userAgent,
|
|
125
|
+
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
|
126
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
127
|
+
'Connection': 'keep-alive'
|
|
128
|
+
};
|
|
129
|
+
const getter = u.protocol === 'https:' ? require('https').get : require('http').get;
|
|
130
|
+
const req = getter(url, { agent, timeout: 30000, headers }, (res) => {
|
|
131
|
+
// 处理重定向
|
|
132
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
133
|
+
req.destroy();
|
|
134
|
+
logger.debug(`重定向: ${url} -> ${res.headers.location}`);
|
|
135
|
+
downloadAndMapUrl(res.headers.location, proxy, userAgent, localDownloadDir, onebotReadDir, logger)
|
|
136
|
+
.then(resolve)
|
|
137
|
+
.catch(reject);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (res.statusCode !== 200) {
|
|
141
|
+
req.destroy();
|
|
142
|
+
reject(new Error(`HTTP ${res.statusCode} when fetching ${url}`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// 检查内容类型,避免下载非图片内容
|
|
146
|
+
const contentType = res.headers['content-type'] || '';
|
|
147
|
+
if (!contentType.startsWith('image/') && !contentType.includes('video/')) {
|
|
148
|
+
req.destroy();
|
|
149
|
+
reject(new Error(`Unexpected content type: ${contentType}`));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const pipelineAsync = (0, util_1.promisify)(stream_1.pipeline);
|
|
153
|
+
pipelineAsync(res, (0, fs_1.createWriteStream)(actualPath))
|
|
154
|
+
.then(() => {
|
|
155
|
+
logger.debug(`下载成功: ${url} -> ${fileUrl}`);
|
|
156
|
+
resolve(fileUrl);
|
|
157
|
+
})
|
|
158
|
+
.catch((err) => {
|
|
159
|
+
req.destroy();
|
|
160
|
+
reject(new Error(`Pipeline failed: ${err.message}`));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
req.on('error', (err) => {
|
|
164
|
+
req.destroy();
|
|
165
|
+
reject(new Error(`Request error: ${err.message}`));
|
|
166
|
+
});
|
|
167
|
+
req.on('timeout', () => {
|
|
168
|
+
req.destroy();
|
|
169
|
+
reject(new Error('Request timeout'));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function getFileSize(url, proxy, userAgent) {
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
const u = new url_1.URL(url);
|
|
176
|
+
const agent = getProxyAgent(proxy, url);
|
|
177
|
+
const getter = u.protocol === 'https:' ? require('https').get : require('http').get;
|
|
178
|
+
const req = getter(url, {
|
|
179
|
+
agent,
|
|
180
|
+
method: 'HEAD',
|
|
181
|
+
timeout: 10000,
|
|
182
|
+
headers: {
|
|
183
|
+
'User-Agent': userAgent
|
|
184
|
+
}
|
|
185
|
+
}, (res) => {
|
|
186
|
+
const len = res.headers['content-length'];
|
|
187
|
+
if (len && /^\d+$/.test(len)) {
|
|
188
|
+
resolve(parseInt(len, 10));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
resolve(null);
|
|
192
|
+
}
|
|
193
|
+
req.destroy();
|
|
194
|
+
});
|
|
195
|
+
req.on('error', () => {
|
|
196
|
+
req.destroy();
|
|
197
|
+
resolve(null);
|
|
198
|
+
});
|
|
199
|
+
req.on('timeout', () => {
|
|
200
|
+
req.destroy();
|
|
201
|
+
resolve(null);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
34
205
|
async function sendResult_plain(session, config, result, logger) {
|
|
35
206
|
if (config.logLevel === 'full') {
|
|
36
|
-
logger.info('
|
|
207
|
+
logger.info('进入普通发送');
|
|
208
|
+
}
|
|
209
|
+
const localDownloadDir = config.localDownloadDir;
|
|
210
|
+
const onebotReadDir = config.onebotReadDir;
|
|
211
|
+
let mediaCoverUrl = result.coverUrl;
|
|
212
|
+
let mediaVideoUrl = result.videoUrl || null;
|
|
213
|
+
let mediaMainbody = result.mainbody;
|
|
214
|
+
// --- 下载封面 ---
|
|
215
|
+
if (result.coverUrl) {
|
|
216
|
+
try {
|
|
217
|
+
mediaCoverUrl = await downloadAndMapUrl(result.coverUrl, config.proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
|
|
218
|
+
if (config.logLevel === 'full')
|
|
219
|
+
logger.info(`封面已下载: ${mediaCoverUrl}`);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
logger.warn(`封面下载失败: ${result.coverUrl}`, e);
|
|
223
|
+
mediaCoverUrl = '';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// --- 视频:先检查大小 ---
|
|
227
|
+
let videoExceedsLimit = false;
|
|
228
|
+
if (result.videoUrl) {
|
|
229
|
+
const sizeBytes = await getFileSize(result.videoUrl, config.proxy, config.userAgent);
|
|
230
|
+
const maxBytes = config.Max_size !== undefined ? config.Max_size * 1024 * 1024 : undefined;
|
|
231
|
+
// 日志用 MB(保留 2 位小数)
|
|
232
|
+
const formatMB = (bytes) => (bytes / (1024 * 1024)).toFixed(2);
|
|
233
|
+
if (sizeBytes === null) {
|
|
234
|
+
logger.warn(`无法获取视频大小: ${result.videoUrl},默认允许下载`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const sizeMB = formatMB(sizeBytes);
|
|
238
|
+
if (maxBytes !== undefined && sizeBytes > maxBytes) {
|
|
239
|
+
videoExceedsLimit = true;
|
|
240
|
+
mediaVideoUrl = null;
|
|
241
|
+
const maxMB = config.Max_size.toFixed(2);
|
|
242
|
+
if (config.logLevel !== 'none') {
|
|
243
|
+
logger.info(`视频大小超限 (${sizeMB} MB > ${maxMB} MB): ${result.videoUrl}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// 大小合规,执行下载
|
|
248
|
+
try {
|
|
249
|
+
mediaVideoUrl = await downloadAndMapUrl(result.videoUrl, config.proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
|
|
250
|
+
if (config.logLevel === 'full') {
|
|
251
|
+
logger.info(`视频已下载 (${sizeMB} MB): ${mediaVideoUrl}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
logger.warn(`视频下载失败: ${result.videoUrl}`, e);
|
|
256
|
+
mediaVideoUrl = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// --- 下载 mainbody 中的图片 ---
|
|
262
|
+
if (result.mainbody) {
|
|
263
|
+
const imgMatches = [...result.mainbody.matchAll(/<img\s[^>]*src\s*=\s*["']?([^"'>\s]+)["']?/gi)];
|
|
264
|
+
const urlMap = {};
|
|
265
|
+
await Promise.all(imgMatches.map(async (match) => {
|
|
266
|
+
const remoteUrl = match[1];
|
|
267
|
+
try {
|
|
268
|
+
const localUrl = await downloadAndMapUrl(remoteUrl, config.proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
|
|
269
|
+
urlMap[remoteUrl] = localUrl;
|
|
270
|
+
if (config.logLevel === 'full')
|
|
271
|
+
logger.info(`正文图片已下载: ${localUrl}`);
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
logger.warn(`正文图片下载失败: ${remoteUrl}`, e);
|
|
275
|
+
}
|
|
276
|
+
}));
|
|
277
|
+
mediaMainbody = result.mainbody;
|
|
278
|
+
for (const [remote, local] of Object.entries(urlMap)) {
|
|
279
|
+
const escaped = remote.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
280
|
+
mediaMainbody = mediaMainbody.replace(new RegExp(escaped, 'g'), local);
|
|
281
|
+
}
|
|
37
282
|
}
|
|
283
|
+
// === 模板替换 ===
|
|
38
284
|
let message = config.format;
|
|
39
|
-
// 对所有文本内容进行 HTML 转义
|
|
40
285
|
message = message.replace(/{title}/g, escapeHtml(result.title || ''));
|
|
41
286
|
message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
|
|
42
|
-
message = message.replace(/{
|
|
287
|
+
message = message.replace(/{mainbody}/g, mediaMainbody ?? '');
|
|
43
288
|
message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
|
|
44
|
-
message = message.replace(/{cover}/g,
|
|
45
|
-
const imagesText = result.images ? result.images.map(img => koishi_1.h.image(img).toString()).join('\n') : '';
|
|
46
|
-
message = message.replace(/{images}/g, imagesText);
|
|
289
|
+
message = message.replace(/{cover}/g, mediaCoverUrl ? koishi_1.h.image(mediaCoverUrl).toString() : '');
|
|
47
290
|
message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
|
|
48
|
-
//
|
|
291
|
+
// 处理视频相关占位符
|
|
49
292
|
if (result.videoUrl) {
|
|
50
293
|
message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const tip = escapeHtml(config.Maximumduration_tip || '');
|
|
294
|
+
if (videoExceedsLimit) {
|
|
295
|
+
const tip = escapeHtml(config.Max_size_tip);
|
|
54
296
|
message = message.replace(/{video}/g, tip);
|
|
55
297
|
}
|
|
298
|
+
else if (mediaVideoUrl) {
|
|
299
|
+
message = message.replace(/{video}/g, koishi_1.h.video(mediaVideoUrl).toString());
|
|
300
|
+
}
|
|
56
301
|
else {
|
|
57
|
-
|
|
58
|
-
message = message.replace(/{video}/g, koishi_1.h.video(result.videoUrl).toString());
|
|
302
|
+
message = message.replace(/{video}/g, '');
|
|
59
303
|
}
|
|
60
304
|
if (config.logLevel === 'link_only') {
|
|
61
305
|
logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
|
|
62
306
|
}
|
|
63
307
|
}
|
|
64
308
|
else {
|
|
65
|
-
// 没有视频则移除占位符
|
|
66
309
|
message = message.replace(/{video}/g, '');
|
|
67
310
|
message = message.replace(/{videoUrl}/g, '');
|
|
68
311
|
}
|
|
69
|
-
// 过滤空行,保留含有 < 的行(如图片、视频标签)
|
|
70
312
|
const cleanMessage = message.split('\n').filter(line => line.trim() !== '' || line.includes('<')).join('\n');
|
|
71
313
|
if (config.logLevel === 'full') {
|
|
72
314
|
logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
|
|
@@ -75,201 +317,138 @@ async function sendResult_plain(session, config, result, logger) {
|
|
|
75
317
|
await session.send(koishi_1.h.quote(session.messageId) + cleanMessage);
|
|
76
318
|
}
|
|
77
319
|
}
|
|
78
|
-
async function sendResult_forward(session, config, result, logger) {
|
|
320
|
+
async function sendResult_forward(session, config, result, logger, mixed_sending = false) {
|
|
79
321
|
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);
|
|
322
|
+
logger.info(mixed_sending ? '进入混合发送' : '进入合并发送');
|
|
95
323
|
}
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
324
|
+
const localDownloadDir = config.localDownloadDir;
|
|
325
|
+
const onebotReadDir = config.onebotReadDir;
|
|
326
|
+
let mediaCoverUrl = result.coverUrl;
|
|
327
|
+
let mediaVideoUrl = result.videoUrl || null;
|
|
328
|
+
let mediaMainbody = result.mainbody;
|
|
329
|
+
// --- 封面 ---
|
|
101
330
|
if (result.coverUrl) {
|
|
102
|
-
|
|
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);
|
|
331
|
+
try {
|
|
332
|
+
mediaCoverUrl = await downloadAndMapUrl(result.coverUrl, config.proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
|
|
143
333
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
334
|
+
catch (e) {
|
|
335
|
+
logger.warn('封面下载失败', e);
|
|
336
|
+
mediaCoverUrl = '';
|
|
147
337
|
}
|
|
148
338
|
}
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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) {
|
|
339
|
+
// --- 视频大小检查 + 下载 ---
|
|
340
|
+
let videoExceedsLimit = false;
|
|
341
|
+
if (result.videoUrl) {
|
|
342
|
+
const sizeBytes = await getFileSize(result.videoUrl, config.proxy, config.userAgent);
|
|
343
|
+
const maxBytes = config.Max_size !== undefined ? config.Max_size * 1024 * 1024 : undefined;
|
|
344
|
+
const formatMB = (bytes) => (bytes / (1024 * 1024)).toFixed(2);
|
|
345
|
+
if (sizeBytes === null) {
|
|
346
|
+
logger.warn(`无法获取视频大小: ${result.videoUrl},默认允许下载`);
|
|
165
347
|
}
|
|
166
348
|
else {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
]
|
|
349
|
+
const sizeMB = formatMB(sizeBytes);
|
|
350
|
+
if (maxBytes !== undefined && sizeBytes > maxBytes) {
|
|
351
|
+
videoExceedsLimit = true;
|
|
352
|
+
mediaVideoUrl = null;
|
|
353
|
+
const maxMB = config.Max_size.toFixed(2);
|
|
354
|
+
if (config.logLevel !== 'none') {
|
|
355
|
+
logger.info(`视频大小超限 (${sizeMB} MB > ${maxMB} MB): ${result.videoUrl}`);
|
|
175
356
|
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
try {
|
|
360
|
+
mediaVideoUrl = await downloadAndMapUrl(result.videoUrl, config.proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
|
|
361
|
+
if (config.logLevel === 'full') {
|
|
362
|
+
logger.info(`视频已下载 (${sizeMB} MB): ${mediaVideoUrl}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
logger.warn('视频下载失败', e);
|
|
367
|
+
mediaVideoUrl = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
180
370
|
}
|
|
181
371
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
async function sendResult_mixed(session, config, result, logger) {
|
|
200
|
-
if (config.logLevel === 'full') {
|
|
201
|
-
logger.info('进入混合转发发送');
|
|
372
|
+
// --- mainbody 图片 ---
|
|
373
|
+
if (result.mainbody) {
|
|
374
|
+
const imgUrls = [...result.mainbody.matchAll(/<img\s[^>]*src\s*=\s*["']?([^"'>\s]+)["']?/gi)].map(m => m[1]);
|
|
375
|
+
const urlMap = {};
|
|
376
|
+
await Promise.all(imgUrls.map(async (url) => {
|
|
377
|
+
try {
|
|
378
|
+
urlMap[url] = await downloadAndMapUrl(url, config.proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
logger.warn(`正文图片下载失败: ${url}`, e);
|
|
382
|
+
}
|
|
383
|
+
}));
|
|
384
|
+
mediaMainbody = result.mainbody;
|
|
385
|
+
for (const [remote, local] of Object.entries(urlMap)) {
|
|
386
|
+
const escaped = remote.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
387
|
+
mediaMainbody = mediaMainbody.replace(new RegExp(escaped, 'g'), local);
|
|
388
|
+
}
|
|
202
389
|
}
|
|
390
|
+
// === 构建消息模板 ===
|
|
203
391
|
let message = config.format;
|
|
204
|
-
// Step 1: 替换纯文本字段
|
|
205
392
|
message = message.replace(/{title}/g, escapeHtml(result.title || ''));
|
|
206
393
|
message = message.replace(/{authorName}/g, escapeHtml(result.authorName || ''));
|
|
207
|
-
message = message.replace(/{description}/g, escapeHtml(result.description || ''));
|
|
208
394
|
message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
|
|
209
395
|
message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
|
|
396
|
+
// 处理 {videoUrl} 和 {video} 占位符逻辑(用于后续判断)
|
|
210
397
|
if (result.videoUrl) {
|
|
211
398
|
message = message.replace(/{videoUrl}/g, escapeHtml(result.videoUrl));
|
|
399
|
+
if (videoExceedsLimit) {
|
|
400
|
+
const tip = escapeHtml(config.Max_size_tip);
|
|
401
|
+
message = message.replace(/{video}/g, tip);
|
|
402
|
+
}
|
|
403
|
+
// 注意:这里不替换 {video} 为实际视频,留到转发节点构建时处理
|
|
212
404
|
}
|
|
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
405
|
const hasVideoInTemplate = message.includes('{video}');
|
|
219
|
-
// Step 3: 构建富媒体映射
|
|
220
406
|
const mediaMap = {};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
mediaMap['{cover}'] = [{ type: 'image', data: { file: result.coverUrl } }];
|
|
407
|
+
if (mediaCoverUrl) {
|
|
408
|
+
mediaMap['{cover}'] = [{ type: 'image', data: { file: mediaCoverUrl } }];
|
|
224
409
|
}
|
|
225
410
|
else {
|
|
226
411
|
mediaMap['{cover}'] = [];
|
|
227
412
|
}
|
|
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
413
|
const lines = message.split('\n').filter(line => line.trim() !== '');
|
|
237
414
|
const nonVideoSegments = [];
|
|
238
415
|
for (let i = 0; i < lines.length; i++) {
|
|
239
416
|
const line = lines[i];
|
|
240
417
|
const isLastLine = i === lines.length - 1;
|
|
241
|
-
|
|
242
|
-
const tokens = line.split(/(\{cover\}|\{images\}|\{video\})/g);
|
|
243
|
-
// 用于存储当前行的消息段
|
|
418
|
+
const tokens = line.split(/(\{cover\}|\{video\})/g);
|
|
244
419
|
const currentLineSegments = [];
|
|
245
|
-
let hasTextContent = false;
|
|
420
|
+
let hasTextContent = false;
|
|
246
421
|
for (const token of tokens) {
|
|
247
|
-
if (token === '{cover}'
|
|
248
|
-
// 插入对应的消息段
|
|
422
|
+
if (token === '{cover}') {
|
|
249
423
|
currentLineSegments.push(...mediaMap[token]);
|
|
250
424
|
}
|
|
425
|
+
else if (token === '{mainbody}') {
|
|
426
|
+
const parsed = parseHtmlToSegments(mediaMainbody || '');
|
|
427
|
+
currentLineSegments.push(...parsed);
|
|
428
|
+
hasTextContent = parsed.some(seg => seg.type === 'text');
|
|
429
|
+
}
|
|
251
430
|
else if (token === '{video}') {
|
|
252
|
-
//
|
|
431
|
+
// 超限时替换为提示文本;否则留空(由转发节点处理)
|
|
432
|
+
if (videoExceedsLimit) {
|
|
433
|
+
const tip = config.Max_size_tip;
|
|
434
|
+
currentLineSegments.push({ type: 'text', data: { text: tip } });
|
|
435
|
+
hasTextContent = true;
|
|
436
|
+
}
|
|
437
|
+
// 否则不插入内容(视频将作为独立节点)
|
|
253
438
|
}
|
|
254
439
|
else if (token.trim() !== '') {
|
|
255
|
-
// 普通文本
|
|
256
440
|
currentLineSegments.push({ type: 'text', data: { text: token } });
|
|
257
|
-
hasTextContent = true;
|
|
441
|
+
hasTextContent = true;
|
|
258
442
|
}
|
|
259
|
-
// 注意:token 为空字符串时(如占位符在行首/尾),不添加任何内容
|
|
260
443
|
}
|
|
261
|
-
// 只有当 currentLineSegments 不为空时,才将其加入总列表
|
|
262
444
|
if (currentLineSegments.length > 0) {
|
|
263
445
|
nonVideoSegments.push(...currentLineSegments);
|
|
264
446
|
}
|
|
265
|
-
// 如果不是最后一行,且当前行非空,则添加一个换行符
|
|
266
447
|
if (!isLastLine && hasTextContent) {
|
|
267
448
|
nonVideoSegments.push({ type: 'text', data: { text: '\n' } });
|
|
268
449
|
}
|
|
269
450
|
}
|
|
270
|
-
// Step 5: 构建转发节点
|
|
271
451
|
const forwardNodes = [];
|
|
272
|
-
// 非视频内容节点
|
|
273
452
|
if (nonVideoSegments.length > 0) {
|
|
274
453
|
forwardNodes.push({
|
|
275
454
|
type: 'node',
|
|
@@ -280,13 +459,20 @@ async function sendResult_mixed(session, config, result, logger) {
|
|
|
280
459
|
}
|
|
281
460
|
});
|
|
282
461
|
}
|
|
283
|
-
let
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
462
|
+
let videoElement;
|
|
463
|
+
if (hasVideoInTemplate && result.videoUrl && !videoExceedsLimit && mediaVideoUrl) {
|
|
464
|
+
if (!mixed_sending) {
|
|
465
|
+
forwardNodes.push({
|
|
466
|
+
type: 'node',
|
|
467
|
+
data: {
|
|
468
|
+
user_id: session.selfId,
|
|
469
|
+
nickname: '分享助手',
|
|
470
|
+
content: [{ type: 'video', data: { file: mediaVideoUrl } }]
|
|
471
|
+
}
|
|
472
|
+
});
|
|
287
473
|
}
|
|
288
474
|
else {
|
|
289
|
-
|
|
475
|
+
videoElement = koishi_1.h.video(mediaVideoUrl).toString();
|
|
290
476
|
}
|
|
291
477
|
if (config.logLevel === 'link_only') {
|
|
292
478
|
logger.info(`视频直链 (${result.platform}): ${result.videoUrl}`);
|
|
@@ -297,19 +483,19 @@ async function sendResult_mixed(session, config, result, logger) {
|
|
|
297
483
|
if (config.logLevel === 'full') {
|
|
298
484
|
logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
|
|
299
485
|
}
|
|
300
|
-
// Step 6: 发送合并转发
|
|
301
486
|
if (!(session.onebot && session.onebot._request))
|
|
302
487
|
throw new Error("Onebot is not defined");
|
|
303
488
|
const promises = [];
|
|
304
489
|
promises.push(session.onebot._request('send_group_forward_msg', {
|
|
305
490
|
group_id: session.guildId,
|
|
306
491
|
messages: forwardNodes,
|
|
307
|
-
news: [{ text:
|
|
492
|
+
news: [{ text: mediaMainbody || '-' }, { text: '点击查看详情 | Powered by furryaxw' }],
|
|
308
493
|
prompt: result.title || '',
|
|
309
494
|
summary: '分享解析',
|
|
310
495
|
source: result.title || ''
|
|
311
496
|
}));
|
|
312
|
-
if (
|
|
313
|
-
promises.push(session.send(
|
|
497
|
+
if (mixed_sending && videoElement) {
|
|
498
|
+
promises.push(session.send(videoElement));
|
|
499
|
+
}
|
|
314
500
|
await Promise.all(promises);
|
|
315
501
|
}
|