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 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
- if (!session?.guildId || !session?.userId)
745
- return '该指令只能在群组中使用。';
746
- const { parsers, nsfw } = await (0, utils_1.getEffectiveSettings)(ctx, session.guildId, config);
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]', '查看/管理当前群的解析器状态', { authority: 1 })
767
+ cmd.subcommand('.parsers [parser:string] [mode:string]', '查看/管理当前会话的解析器状态', { authority: 1 })
753
768
  .action(async ({ session }, parser, value) => {
754
- if (!session?.guildId || !session?.userId)
755
- return '该指令只能在群组中使用。';
756
- if (!await (0, utils_1.isUserAdmin)(session, session.userId))
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', session.guildId);
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: session.guildId, custom_parsers: final_parsers };
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
- if (!session?.guildId || !session?.userId)
772
- return '该指令只能在群组中使用。';
773
- if (!await (0, utils_1.isUserAdmin)(session, session.userId))
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: session.guildId, nsfw_enabled: mode };
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
- if (!session?.guildId || !session?.userId)
783
- return '该指令只能在群组中使用。';
784
- if (!await (0, utils_1.isUserAdmin)(session, session.userId))
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: session.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
- if (session.guildId) {
1114
- const settings = await (0, utils_1.getEffectiveSettings)(ctx, session.guildId, config);
1115
- if (!settings.parsers[link.platform]) {
1116
- logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
1117
- if (config.showError)
1118
- await session.send(`根据策略,该链接已被阻止解析:平台:${link.platform}\n如果你是管理员,你可以通过#help share指令获取更多帮助`);
1119
- continue;
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 {
@@ -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/${m[1]}/status/${id}`;
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 短链 (适用于 VxTwitter API 或上一步没清理干净的情况)
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
- * 从 VxTwitter API 数据对象中提取内容
215
- * 适用于 api.vxtwitter.com 数据结构
214
+ * 将 FxTwitter 的 raw_text.facets 转换为 entities 结构,供 processTweetText 复用:
215
+ * media 段用于剔除附件链接,url 段用于替换为展开后的地址
216
216
  */
217
- function extractVxContent(data) {
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
- if (data.media_extended && Array.isArray(data.media_extended)) {
222
- for (const media of data.media_extended) {
223
- if (media.type === 'image') {
224
- images.push(media.url);
225
- }
226
- else if (media.type === 'video' || media.type === 'gif') {
227
- files.push(createTwitterVideoFile(media.url));
228
- if (!videoPoster && media.thumbnail_url) {
229
- videoPoster = media.thumbnail_url;
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: data.text || '',
236
- screenName: data.user_screen_name || 'unknown',
237
- authorName: data.user_name || 'Unknown',
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
- // 首选: VxTwitter API
340
+ // 首选: FxTwitter API
329
341
  try {
330
- const apiUrl = `https://api.vxtwitter.com/Twitter/status/${tweetId}`;
331
- logger.debug(`请求 VxAPI: ${apiUrl}`);
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 data = await ctx.http.get(apiUrl, reqOptions);
340
- if (!data)
341
- throw new Error('VxAPI 返回无效');
342
- const main = extractVxContent(data);
343
- const statsString = `点赞: ${(0, utils_1.numeral)(data.likes, config)} | 评论: ${(0, utils_1.numeral)(data.replies, config)} | 转发: ${(0, utils_1.numeral)(data.retweets, config)}`;
344
- let processedMainText = await processTweetText(ctx, main.text, tweetId, data, config);
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
- let quoteData = data.qrt;
368
+ const quoteData = tweet.quote;
353
369
  if (quoteData) {
354
- // 兼容各种返回结构中的 ID 字段
355
- const quoteId = quoteData.tweetID || quoteData.tweet_id || "unknown";
356
- const quote = extractVxContent(quoteData);
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
- // 如果是 429 (Rate Limit) 或其他非 404 错误,尝试 Fallback 到 Syndication
391
- // 404 通常意味着推文真的没了
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(`VxTwitter API 失败 (${error.message}${causeDetail}),尝试 Syndication API Fallback...`);
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 (error.response?.status === 404) {
410
+ if (isNotFound) {
410
411
  await session.send('推文不存在或已被删除');
411
412
  }
412
413
  else if (error.response?.status === 429) {