koishi-plugin-share-links-analysis 0.15.2 → 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +51 -33
- package/lib/parsers/twitter.js +71 -70
- package/lib/parsers/xiaoheihe.js +222 -288
- package/lib/parsers/xiaohongshu.d.ts +1 -0
- package/lib/parsers/xiaohongshu.js +183 -53
- package/lib/types.d.ts +39 -19
- package/lib/utils.d.ts +5 -1
- package/lib/utils.js +10 -3
- package/package.json +47 -47
package/lib/index.js
CHANGED
|
@@ -98,6 +98,10 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
98
98
|
parseLimit: koishi_1.Schema.number().default(3).description("单对话多链接解析上限"),
|
|
99
99
|
useNumeral: koishi_1.Schema.boolean().default(true).description("使用格式化数字 (如 10000 -> 1万)"),
|
|
100
100
|
showError: koishi_1.Schema.boolean().default(false).description("当链接被阻止时提醒发送者"),
|
|
101
|
+
sensitiveMessage: koishi_1.Schema.string().role('textarea').default(`内容包含敏感信息,已停止解析
|
|
102
|
+
如果你是管理员,你可以通过 help share 指令获取更多帮助`).description("内容被判定为敏感且当前会话不允许时返回的提示文案。"),
|
|
103
|
+
blockedMessage: koishi_1.Schema.string().role('textarea').default(`根据策略,该链接已被阻止解析:平台:{platform}
|
|
104
|
+
如果你是管理员,你可以通过 help share 指令获取更多帮助`).description("链接被会话策略阻止时返回的提示文案。<br/>可用占位符: `{platform}`"),
|
|
101
105
|
}).description("高级解析设置"),
|
|
102
106
|
koishi_1.Schema.object({
|
|
103
107
|
cookieCloud: koishi_1.Schema.object({
|
|
@@ -739,21 +743,33 @@ function apply(ctx, config) {
|
|
|
739
743
|
ctx.setInterval(cleanExpiredCache, config.autoCleanInterval * 60 * 60 * 1000);
|
|
740
744
|
}
|
|
741
745
|
// 注册指令
|
|
746
|
+
/**
|
|
747
|
+
* 会话设置的管理权限:群聊要求管理员,私聊里会话本身就是自己的作用域
|
|
748
|
+
*/
|
|
749
|
+
const canManageSettings = async (session) => {
|
|
750
|
+
if (!session.guildId)
|
|
751
|
+
return true;
|
|
752
|
+
if (!session.userId)
|
|
753
|
+
return false;
|
|
754
|
+
return await (0, utils_1.isUserAdmin)(session, session.userId);
|
|
755
|
+
};
|
|
742
756
|
const cmd = ctx.command('share', '分享解析插件配置', { authority: 1 })
|
|
743
757
|
.action(async ({ session }) => {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
758
|
+
const scope = session && (0, utils_1.getSettingsScope)(session);
|
|
759
|
+
if (!scope)
|
|
760
|
+
return '当前会话不可用。';
|
|
761
|
+
const { parsers, nsfw } = await (0, utils_1.getEffectiveSettings)(ctx, scope, config);
|
|
747
762
|
const parserList = Object.entries(parsers)
|
|
748
763
|
.map(([name, enabled]) => `${enabled ? '✅' : '❌'} ${name}`)
|
|
749
764
|
.join('\n');
|
|
750
765
|
return `当前解析器状态:\n${parserList}\nNSFW 内容:${nsfw ? '✅' : '❌'}`.trim();
|
|
751
766
|
});
|
|
752
|
-
cmd.subcommand('.parsers [parser:string] [mode:string]', '
|
|
767
|
+
cmd.subcommand('.parsers [parser:string] [mode:string]', '查看/管理当前会话的解析器状态', { authority: 1 })
|
|
753
768
|
.action(async ({ session }, parser, value) => {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
769
|
+
const scope = session && (0, utils_1.getSettingsScope)(session);
|
|
770
|
+
if (!scope)
|
|
771
|
+
return '当前会话不可用。';
|
|
772
|
+
if (!await canManageSettings(session))
|
|
757
773
|
return '权限不足';
|
|
758
774
|
const isValidParser = (name) => core_1.parsers_str.includes(name);
|
|
759
775
|
if (!isValidParser(parser))
|
|
@@ -761,29 +777,31 @@ function apply(ctx, config) {
|
|
|
761
777
|
if (!value)
|
|
762
778
|
return '请输入正确的模式';
|
|
763
779
|
const mode = value.trim().toLowerCase() === 'true';
|
|
764
|
-
const data = await ctx.database.get('sla_group_settings',
|
|
780
|
+
const data = await ctx.database.get('sla_group_settings', scope);
|
|
765
781
|
const final_parsers = { ...data[0]?.custom_parsers, ...{ [parser]: mode } };
|
|
766
|
-
const record = { guildId:
|
|
782
|
+
const record = { guildId: scope, custom_parsers: final_parsers };
|
|
767
783
|
await ctx.database.upsert('sla_group_settings', [record]);
|
|
768
784
|
});
|
|
769
785
|
cmd.subcommand('.nsfw [value:string]', '设置是否允许 NSFW 内容', { authority: 1 })
|
|
770
786
|
.action(async ({ session }, value) => {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
787
|
+
const scope = session && (0, utils_1.getSettingsScope)(session);
|
|
788
|
+
if (!scope)
|
|
789
|
+
return '当前会话不可用。';
|
|
790
|
+
if (!await canManageSettings(session))
|
|
774
791
|
return '权限不足';
|
|
775
792
|
const mode = value.trim().toLowerCase() === 'true';
|
|
776
|
-
const record = { guildId:
|
|
793
|
+
const record = { guildId: scope, nsfw_enabled: mode };
|
|
777
794
|
await ctx.database.upsert('sla_group_settings', [record]);
|
|
778
795
|
await session.execute('share');
|
|
779
796
|
});
|
|
780
797
|
cmd.subcommand('.reset', '重置为全局默认设置', { authority: 1 })
|
|
781
798
|
.action(async ({ session }) => {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
799
|
+
const scope = session && (0, utils_1.getSettingsScope)(session);
|
|
800
|
+
if (!scope)
|
|
801
|
+
return '当前会话不可用。';
|
|
802
|
+
if (!await canManageSettings(session))
|
|
785
803
|
return '权限不足';
|
|
786
|
-
await ctx.database.remove('sla_group_settings', { guildId:
|
|
804
|
+
await ctx.database.remove('sla_group_settings', { guildId: scope });
|
|
787
805
|
return '已重置为全局默认设置。';
|
|
788
806
|
});
|
|
789
807
|
cmd.subcommand('.migratecache', '迁移旧解析缓存到新时间线并校验', { authority: 4 })
|
|
@@ -1110,22 +1128,13 @@ function apply(ctx, config) {
|
|
|
1110
1128
|
return next();
|
|
1111
1129
|
let linkCount = 0;
|
|
1112
1130
|
for (const link of links) {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
else {
|
|
1123
|
-
if (!config.default_parsers[link.platform]) {
|
|
1124
|
-
logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
|
|
1125
|
-
if (config.showError)
|
|
1126
|
-
await session.send(`根据策略,该链接已被阻止解析:平台:${link.platform}`);
|
|
1127
|
-
continue;
|
|
1128
|
-
}
|
|
1131
|
+
// 会话策略(群组自定义或全局默认)在解析前后都要用到,这里取一次
|
|
1132
|
+
const settings = await (0, utils_1.getEffectiveSettings)(ctx, (0, utils_1.getSettingsScope)(session), config);
|
|
1133
|
+
if (!settings.parsers[link.platform]) {
|
|
1134
|
+
logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
|
|
1135
|
+
if (config.showError)
|
|
1136
|
+
await session.send(config.blockedMessage.replace(/\{platform\}/g, link.platform));
|
|
1137
|
+
continue;
|
|
1129
1138
|
}
|
|
1130
1139
|
if (linkCount >= config.parseLimit) {
|
|
1131
1140
|
await session.send("已达到单次解析上限…");
|
|
@@ -1292,6 +1301,15 @@ function apply(ctx, config) {
|
|
|
1292
1301
|
}
|
|
1293
1302
|
if (result) {
|
|
1294
1303
|
lastProcessedUrls[channelId][link.url] = Date.now();
|
|
1304
|
+
// 敏感内容在会话层统一拦截:解析结果来自缓存时同样要过这一关
|
|
1305
|
+
if (result.sensitive && !settings.nsfw) {
|
|
1306
|
+
result = {
|
|
1307
|
+
...result,
|
|
1308
|
+
mainbody: (0, utils_1.escapeHtml)(config.sensitiveMessage),
|
|
1309
|
+
coverUrl: undefined,
|
|
1310
|
+
files: []
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1295
1313
|
await (0, utils_1.sendResult)(ctx, session, config, result, logger, sendStats);
|
|
1296
1314
|
}
|
|
1297
1315
|
else {
|
package/lib/parsers/twitter.js
CHANGED
|
@@ -43,7 +43,7 @@ async function match(content, ctx, config) {
|
|
|
43
43
|
rule.pattern.lastIndex = 0;
|
|
44
44
|
while ((m = rule.pattern.exec(content)) !== null) {
|
|
45
45
|
const id = m[2] || m[1];
|
|
46
|
-
const url = rule.type === 'short' ? `https://t.co/${id}` : `https://x.com
|
|
46
|
+
const url = rule.type === 'short' ? `https://t.co/${id}` : `https://x.com/i/status/${id}`;
|
|
47
47
|
initialLinks.push({ platform: exports.name, type: rule.type, id, url });
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -99,7 +99,7 @@ async function processTweetText(ctx, text, tweetId, data, config) {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
// 2. 扫描并处理残留的 t.co 短链 (
|
|
102
|
+
// 2. 扫描并处理残留的 t.co 短链 (上一步没清理干净的情况)
|
|
103
103
|
const tcoRegex = /https:\/\/t\.co\/\w+/g;
|
|
104
104
|
const matches = processedText.match(tcoRegex);
|
|
105
105
|
if (matches) {
|
|
@@ -211,30 +211,57 @@ function extractTweetContent(data, config) {
|
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
213
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
214
|
+
* 将 FxTwitter 的 raw_text.facets 转换为 entities 结构,供 processTweetText 复用:
|
|
215
|
+
* media 段用于剔除附件链接,url 段用于替换为展开后的地址
|
|
216
216
|
*/
|
|
217
|
-
function
|
|
217
|
+
function facetsToEntities(tweet) {
|
|
218
|
+
const entities = {};
|
|
219
|
+
const facets = tweet?.raw_text?.facets;
|
|
220
|
+
if (!Array.isArray(facets))
|
|
221
|
+
return entities;
|
|
222
|
+
const media = facets
|
|
223
|
+
.filter((f) => f?.type === 'media' && f.original)
|
|
224
|
+
.map((f) => ({ url: f.original }));
|
|
225
|
+
const urls = facets
|
|
226
|
+
.filter((f) => f?.type === 'url' && f.original && f.replacement)
|
|
227
|
+
.map((f) => ({ url: f.original, expanded_url: f.replacement }));
|
|
228
|
+
if (media.length > 0)
|
|
229
|
+
entities.media = media;
|
|
230
|
+
if (urls.length > 0)
|
|
231
|
+
entities.urls = urls;
|
|
232
|
+
return entities;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 从 FxTwitter API 数据对象中提取内容
|
|
236
|
+
* 适用于 api.fxtwitter.com 的 tweet 结构
|
|
237
|
+
*/
|
|
238
|
+
function extractFxContent(tweet) {
|
|
218
239
|
const images = [];
|
|
219
240
|
const files = [];
|
|
220
241
|
let videoPoster = '';
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
242
|
+
const mediaList = Array.isArray(tweet?.media?.all) && tweet.media.all.length > 0
|
|
243
|
+
? tweet.media.all
|
|
244
|
+
: [...(tweet?.media?.photos ?? []), ...(tweet?.media?.videos ?? [])];
|
|
245
|
+
// 同一视频可能返回多种格式,优先取 mp4
|
|
246
|
+
const videos = mediaList.filter((m) => ['video', 'gif', 'animated_gif'].includes(m?.type));
|
|
247
|
+
const mp4Videos = videos.filter((m) => !m.format || m.format === 'mp4');
|
|
248
|
+
const pickedVideos = mp4Videos.length > 0 ? mp4Videos : videos;
|
|
249
|
+
for (const media of mediaList) {
|
|
250
|
+
if (media?.type === 'photo' && media.url)
|
|
251
|
+
images.push(media.url);
|
|
252
|
+
}
|
|
253
|
+
for (const media of pickedVideos) {
|
|
254
|
+
const videoUrl = media.url || media.src;
|
|
255
|
+
if (videoUrl && !files.some(f => f.url === videoUrl)) {
|
|
256
|
+
files.push(createTwitterVideoFile(videoUrl));
|
|
232
257
|
}
|
|
258
|
+
if (!videoPoster)
|
|
259
|
+
videoPoster = media.thumbnail_url || media.poster || media.thumbnail || '';
|
|
233
260
|
}
|
|
234
261
|
return {
|
|
235
|
-
text:
|
|
236
|
-
screenName:
|
|
237
|
-
authorName:
|
|
262
|
+
text: tweet?.raw_text?.text ?? tweet?.text ?? '',
|
|
263
|
+
screenName: tweet?.author?.screen_name || 'unknown',
|
|
264
|
+
authorName: tweet?.author?.name || 'Unknown',
|
|
238
265
|
images,
|
|
239
266
|
files,
|
|
240
267
|
cover: videoPoster
|
|
@@ -286,22 +313,6 @@ async function handleSyndicationFallback(ctx, config, tweetId, session) {
|
|
|
286
313
|
}
|
|
287
314
|
files.push(...quote.files);
|
|
288
315
|
}
|
|
289
|
-
// 敏感内容检查
|
|
290
|
-
const settings = await (0, utils_1.getEffectiveSettings)(ctx, session.guildId, config);
|
|
291
|
-
if (data.possibly_sensitive && !settings.nsfw) {
|
|
292
|
-
if (config.showError)
|
|
293
|
-
await session.send('内容包含敏感信息,已停止解析\n如果你是管理员,你可以通过#help share指令获取更多帮助');
|
|
294
|
-
return {
|
|
295
|
-
platform: exports.name,
|
|
296
|
-
title: `@${screenName} 的推文`,
|
|
297
|
-
authorName,
|
|
298
|
-
mainbody: (0, utils_1.escapeHtml)("内容包含敏感信息,已停止解析\n如果你是管理员,你可以通过#help share指令获取更多帮助"),
|
|
299
|
-
sourceUrl: `https://x.com/${screenName}/status/${tweetId}`,
|
|
300
|
-
stats: statsString,
|
|
301
|
-
files: [],
|
|
302
|
-
coverUrl
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
316
|
return {
|
|
306
317
|
platform: exports.name,
|
|
307
318
|
title: `@${screenName} 的推文`,
|
|
@@ -310,7 +321,8 @@ async function handleSyndicationFallback(ctx, config, tweetId, session) {
|
|
|
310
321
|
sourceUrl: `https://x.com/${screenName}/status/${tweetId}`,
|
|
311
322
|
stats: statsString,
|
|
312
323
|
files,
|
|
313
|
-
coverUrl
|
|
324
|
+
coverUrl,
|
|
325
|
+
sensitive: !!data.possibly_sensitive
|
|
314
326
|
};
|
|
315
327
|
}
|
|
316
328
|
catch (e) {
|
|
@@ -325,10 +337,10 @@ async function handleSyndicationFallback(ctx, config, tweetId, session) {
|
|
|
325
337
|
async function process(ctx, config, link, session) {
|
|
326
338
|
const logger = ctx.logger(`share-links-analysis:${exports.name}`);
|
|
327
339
|
const tweetId = link.id;
|
|
328
|
-
// 首选:
|
|
340
|
+
// 首选: FxTwitter API
|
|
329
341
|
try {
|
|
330
|
-
const apiUrl = `https://api.
|
|
331
|
-
logger.debug(`请求
|
|
342
|
+
const apiUrl = `https://api.fxtwitter.com/status/${tweetId}`;
|
|
343
|
+
logger.debug(`请求 FxTwitter API: ${apiUrl}`);
|
|
332
344
|
const reqOptions = {
|
|
333
345
|
headers: {
|
|
334
346
|
'User-Agent': config.userAgent
|
|
@@ -336,12 +348,16 @@ async function process(ctx, config, link, session) {
|
|
|
336
348
|
};
|
|
337
349
|
if (config.proxy)
|
|
338
350
|
reqOptions.proxyAgent = config.proxy;
|
|
339
|
-
const
|
|
340
|
-
if (!
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
351
|
+
const payload = await ctx.http.get(apiUrl, reqOptions);
|
|
352
|
+
if (!payload || payload.code !== 200 || !payload.tweet) {
|
|
353
|
+
const apiError = new Error(`FxTwitter API 返回无效 (code: ${payload?.code ?? 'none'}, message: ${payload?.message ?? 'none'})`);
|
|
354
|
+
apiError.apiCode = payload?.code;
|
|
355
|
+
throw apiError;
|
|
356
|
+
}
|
|
357
|
+
const tweet = payload.tweet;
|
|
358
|
+
const main = extractFxContent(tweet);
|
|
359
|
+
const statsString = `点赞: ${(0, utils_1.numeral)(tweet.likes, config)} | 评论: ${(0, utils_1.numeral)(tweet.replies, config)} | 转发: ${(0, utils_1.numeral)(tweet.retweets, config)}`;
|
|
360
|
+
let processedMainText = await processTweetText(ctx, main.text, tweetId, { entities: facetsToEntities(tweet) }, config);
|
|
345
361
|
let mainbody = (0, utils_1.escapeHtml)(processedMainText);
|
|
346
362
|
if (main.images.length > 0) {
|
|
347
363
|
mainbody += '\n' + main.images.map(img => koishi_1.h.image(img).toString()).join('\n');
|
|
@@ -349,32 +365,17 @@ async function process(ctx, config, link, session) {
|
|
|
349
365
|
const files = [...main.files];
|
|
350
366
|
const coverUrl = main.cover;
|
|
351
367
|
// 解析引用推文
|
|
352
|
-
|
|
368
|
+
const quoteData = tweet.quote;
|
|
353
369
|
if (quoteData) {
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const processedQuoteText = await processTweetText(ctx, quote.text, quoteId, quoteData, config);
|
|
370
|
+
const quoteId = quoteData.id || "unknown";
|
|
371
|
+
const quote = extractFxContent(quoteData);
|
|
372
|
+
const processedQuoteText = await processTweetText(ctx, quote.text, quoteId, { entities: facetsToEntities(quoteData) }, config);
|
|
358
373
|
mainbody += `\n----------\n[引用 @${quote.screenName}]: ${(0, utils_1.escapeHtml)(processedQuoteText)}`;
|
|
359
374
|
if (quote.images.length > 0) {
|
|
360
375
|
mainbody += '\n' + quote.images.map(img => koishi_1.h.image(img).toString()).join('\n');
|
|
361
376
|
}
|
|
362
377
|
files.push(...quote.files);
|
|
363
378
|
}
|
|
364
|
-
// 敏感内容检查 (VxTwitter 返回 possibly_sensitive)
|
|
365
|
-
const settings = await (0, utils_1.getEffectiveSettings)(ctx, session.guildId, config);
|
|
366
|
-
if (data.possibly_sensitive && !settings.nsfw) {
|
|
367
|
-
return {
|
|
368
|
-
platform: exports.name,
|
|
369
|
-
title: `@${main.screenName} 的推文`,
|
|
370
|
-
authorName: main.authorName,
|
|
371
|
-
mainbody: (0, utils_1.escapeHtml)("内容包含敏感信息,已停止解析\n如果你是管理员,你可以通过#help share指令获取更多帮助"),
|
|
372
|
-
sourceUrl: `https://x.com/${main.screenName}/status/${tweetId}`,
|
|
373
|
-
stats: statsString,
|
|
374
|
-
files: [],
|
|
375
|
-
coverUrl
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
379
|
return {
|
|
379
380
|
platform: exports.name,
|
|
380
381
|
title: `@${main.screenName} 的推文`,
|
|
@@ -383,16 +384,16 @@ async function process(ctx, config, link, session) {
|
|
|
383
384
|
sourceUrl: `https://x.com/${main.screenName}/status/${tweetId}`,
|
|
384
385
|
stats: statsString,
|
|
385
386
|
files,
|
|
386
|
-
coverUrl
|
|
387
|
+
coverUrl,
|
|
388
|
+
sensitive: !!tweet.possibly_sensitive
|
|
387
389
|
};
|
|
388
390
|
}
|
|
389
391
|
catch (error) {
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
const isNotFound = error.response?.status === 404;
|
|
392
|
+
// 404 表示推文不存在(HTTP 层或 API 层),其余错误尝试 Fallback 到 Syndication
|
|
393
|
+
const isNotFound = error.response?.status === 404 || error.apiCode === 404;
|
|
393
394
|
if (!isNotFound) {
|
|
394
395
|
const causeDetail = error.cause ? ` (cause: ${error.cause.code || error.cause.message || error.cause})` : '';
|
|
395
|
-
logger.warn(`
|
|
396
|
+
logger.warn(`FxTwitter API 失败 (${error.message}${causeDetail}),尝试 Syndication API Fallback...`);
|
|
396
397
|
try {
|
|
397
398
|
return await handleSyndicationFallback(ctx, config, tweetId, session);
|
|
398
399
|
}
|
|
@@ -406,7 +407,7 @@ async function process(ctx, config, link, session) {
|
|
|
406
407
|
logger.error(`Twitter 解析失败: ${error.message}`);
|
|
407
408
|
}
|
|
408
409
|
// 最终错误处理
|
|
409
|
-
if (
|
|
410
|
+
if (isNotFound) {
|
|
410
411
|
await session.send('推文不存在或已被删除');
|
|
411
412
|
}
|
|
412
413
|
else if (error.response?.status === 429) {
|