@sns-parse/core 0.5.0-alpha.1 → 0.6.0-alpha.2

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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 发送编排:把一条解析结果拆成「语义单元」(MessageUnit),按发送策略消费。
3
+ *
4
+ * 本模块发送层无关:只构建 OutboundElement,具体发送交给 OutboundSender。
5
+ *
6
+ * 每个单元带 mergeable 标记:
7
+ * - mergeable=true:概述文字/头像/封面/图片/音乐/提示文案(含受限视频与混淆图提示),
8
+ * 全部并入首条消息——提示说清楚「怎么取」
9
+ * - mergeable=false:后面跟着的干脆的可领取形式,不含其他内容:
10
+ * 视频文件 / 「取视频 <token>」/ 「解混淆 <token>[混淆图]」
11
+ *
12
+ * 消费方式见 core/flush.ts:
13
+ * - sendSingle:合并所有 mergeable 单元为一条,mergeable=false 单元逐条
14
+ * - sendSplit:每个单元一条
15
+ * - sendForward:每个单元一个转发气泡(仅一条时不打包直接发送)
16
+ */
17
+ import type { ParserRuntime } from './runtime';
18
+ import type { ParsedData } from '../types';
19
+ import { type OutboundElement, type OutboundSender, type SessionLike } from '../sender';
20
+ import type { ImageOutcome, VideoOutcome } from '../extensions';
21
+ export interface ProcessedItem {
22
+ text: string;
23
+ parsed: ParsedData;
24
+ images: ImageOutcome[];
25
+ avatar: ImageOutcome;
26
+ cover: ImageOutcome | null;
27
+ video: VideoOutcome;
28
+ /** 推文动图转 GIF 的成品(转换失败为 null,回退发视频) */
29
+ gif?: Buffer | null;
30
+ /** 多视频推文的其余视频及其 GIF 成品/封面(与 extraVideos 一一对应) */
31
+ extraVideos?: VideoOutcome[];
32
+ extraGifs?: (Buffer | null)[];
33
+ extraCovers?: (ImageOutcome | null)[];
34
+ }
35
+ /** 一个待发送的语义单元 */
36
+ export interface MessageUnit {
37
+ content: OutboundElement[];
38
+ /** true=可合并进单条消息;false=必须独立发送(视频文件/混淆图/取件码) */
39
+ mergeable: boolean;
40
+ }
41
+ /** 首条消息的混淆图数量提示(不含取件码,取件码只出现在独立领取消息里) */
42
+ export declare function buildImageHint(rt: ParserRuntime, count: number): string;
43
+ /** 首条消息的受限视频提示(不含取件码;token 只在独立的「取视频 <token>」消息里) */
44
+ export declare function buildVideoHint(rt: ParserRuntime, item: ProcessedItem): string;
45
+ /** 把 ProcessedItem 拆成有序语义单元 */
46
+ export declare function buildUnits(rt: ParserRuntime, item: ProcessedItem): MessageUnit[];
47
+ /**
48
+ * 单条整合:合并所有 mergeable 单元为一条消息(图片超限则退化为逐条),
49
+ * mergeable=false 单元独立逐条。
50
+ */
51
+ export declare function sendSingle(rt: ParserRuntime, sender: OutboundSender, session: SessionLike, item: ProcessedItem, opts?: {
52
+ quoteId?: string;
53
+ }): Promise<void>;
54
+ /** 逐条发送:每个语义单元一条消息 */
55
+ export declare function sendSplit(rt: ParserRuntime, sender: OutboundSender, session: SessionLike, item: ProcessedItem, opts?: {
56
+ quoteId?: string;
57
+ }): Promise<void>;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildImageHint = buildImageHint;
4
+ exports.buildVideoHint = buildVideoHint;
5
+ exports.buildUnits = buildUnits;
6
+ exports.sendSingle = sendSingle;
7
+ exports.sendSplit = sendSplit;
8
+ const common_1 = require("../utils/common");
9
+ const sender_1 = require("../sender");
10
+ const hooks_1 = require("../workflow/hooks");
11
+ /** 首条消息的混淆图数量提示(不含取件码,取件码只出现在独立领取消息里) */
12
+ function buildImageHint(rt, count) {
13
+ const nsfw = rt.config.nsfwPolicy || {};
14
+ return String(nsfw.tokenHintText || '').replace(/\$\{count\}/g, String(count));
15
+ }
16
+ /** 首条消息的受限视频提示(不含取件码;token 只在独立的「取视频 <token>」消息里) */
17
+ function buildVideoHint(rt, item) {
18
+ const nsfw = rt.config.nsfwPolicy || {};
19
+ if (item.video.kind === 'card' && item.video.token) {
20
+ const ttl = rt.config.nsfwVault?.ttlMinutes || 30;
21
+ const until = new Date(Date.now() + ttl * 60000);
22
+ const untilStr = `${String(until.getHours()).padStart(2, '0')}:${String(until.getMinutes()).padStart(2, '0')}`;
23
+ return String(nsfw.videoCardHint || '')
24
+ .replace(/\$\{until\}/g, untilStr)
25
+ .replace(/\$\{ttl\}/g, String(ttl));
26
+ }
27
+ if (item.video.kind === 'link' && item.video.url)
28
+ return `受限视频未在群内发送。视频链接:${item.video.url}`;
29
+ if (item.video.kind === 'drop' && item.parsed.video)
30
+ return '受限视频未在群内发送。';
31
+ return '';
32
+ }
33
+ /** 把 ProcessedItem 拆成有序语义单元 */
34
+ function buildUnits(rt, item) {
35
+ const { config } = rt;
36
+ const p = item.parsed;
37
+ const units = [];
38
+ const push = (content, mergeable) => { if (content.length)
39
+ units.push({ content, mergeable }); };
40
+ // ① 概述文字
41
+ if (item.text && config.showImageText)
42
+ push([sender_1.el.text(item.text)], true);
43
+ // ② 头像(非混淆)
44
+ if (config.showAuthorAvatar && p.avatar && item.avatar.kind !== 'drop' && item.avatar.kind !== 'scrambled' && item.avatar.url) {
45
+ push([sender_1.el.image(item.avatar.url)], true);
46
+ }
47
+ // ③ 封面(非混淆);多视频推文的每个视频封面都进概述区
48
+ if (item.cover && item.cover.kind === 'raw' && item.cover.url
49
+ && p.cover && config.showCoverImage && p.type !== 'live_photo' && p.type !== 'image' && p.type !== 'live') {
50
+ const c = [];
51
+ if (config.showCoverText)
52
+ c.push(sender_1.el.text(config.coverText || '封面:'));
53
+ c.push(sender_1.el.image(item.cover.url));
54
+ push(c, true);
55
+ }
56
+ if (config.showCoverImage && p.type !== 'live_photo' && p.type !== 'image' && p.type !== 'live') {
57
+ for (const ec of item.extraCovers || []) {
58
+ if (ec?.kind === 'raw' && ec.url && ec.url !== item.cover?.url)
59
+ push([sender_1.el.image(ec.url)], true);
60
+ else if (ec?.kind === 'link' && ec.url && ec.url !== item.cover?.url)
61
+ push([sender_1.el.text(`封面链接:${ec.url}`)], true);
62
+ }
63
+ }
64
+ // ④ 音乐封面
65
+ if (config.showMusicCover && p.music.cover)
66
+ push([sender_1.el.image(p.music.cover)], true);
67
+ // ⑤ 非混淆图片(raw 可带 buffer:同源切图合并成品优先按 buffer 发送)
68
+ for (const img of item.images) {
69
+ if (img.kind === 'raw' && img.buffer)
70
+ push([sender_1.el.imageBuffer(img.buffer, 'image/jpeg')], true);
71
+ else if (img.kind === 'raw' && img.url)
72
+ push([sender_1.el.image(img.url)], true);
73
+ else if (img.kind === 'link' && img.url)
74
+ push([sender_1.el.text(`图片链接:${img.url}`)], true);
75
+ }
76
+ // ⑥ 合规视频(必须独立,含视频自动分条);动图已转 GIF 时以图片形式独立发送
77
+ if (item.gif) {
78
+ push([sender_1.el.imageBuffer(item.gif, 'image/gif')], false);
79
+ }
80
+ else if (p.video && p.type !== 'live' && p.type !== 'live_photo' && item.video.kind === 'raw' && item.video.url) {
81
+ push([config.showVideoFile !== false ? sender_1.el.video(item.video.url) : sender_1.el.text(`视频链接:${item.video.url}`)], false);
82
+ }
83
+ // ⑥b 多视频推文:其余视频逐条独立发送(动图转 GIF 优先,受限降级链接)
84
+ for (let i = 0; i < (item.extraVideos?.length || 0); i++) {
85
+ const ev = item.extraVideos[i];
86
+ const eg = item.extraGifs?.[i];
87
+ if (eg) {
88
+ push([sender_1.el.imageBuffer(eg, 'image/gif')], false);
89
+ }
90
+ else if (ev.kind === 'raw' && ev.url) {
91
+ push([config.showVideoFile !== false ? sender_1.el.video(ev.url) : sender_1.el.text(`视频链接:${ev.url}`)], false);
92
+ }
93
+ else if (ev.kind === 'link' && ev.url) {
94
+ push([sender_1.el.text(`视频链接:${ev.url}`)], true);
95
+ }
96
+ }
97
+ // ⑦ 音乐语音
98
+ if (config.showMusicVoice && p.music.url)
99
+ push([sender_1.el.audio(p.music.url)], true);
100
+ // ⑧ 直播提示
101
+ if (p.type === 'live' && config.sendLiveMessage)
102
+ push([sender_1.el.text('直播进行中,无法发送视频流。')], true);
103
+ // ⑨ 受限视频:提示并入首条消息(存在任一受限视频即提示一次);每个取件码独立成条
104
+ const cardOutcomes = [item.video, ...(item.extraVideos || [])]
105
+ .filter(v => v?.kind === 'card' && v.token);
106
+ let videoHint = buildVideoHint(rt, item);
107
+ if (!videoHint && cardOutcomes.length) {
108
+ // 主视频未受限但存在受限的额外视频(按各自封面单独判定):仍给出领取提示
109
+ videoHint = buildVideoHint(rt, { ...item, video: cardOutcomes[0] });
110
+ }
111
+ if (videoHint)
112
+ push([sender_1.el.text(videoHint)], true);
113
+ for (const c of cardOutcomes)
114
+ push([sender_1.el.text(`取视频 ${c.token}`)], false);
115
+ // ⑩ 混淆图:首条只报数量;每张独立为干脆的「解混淆 <token>[混淆图]」(多视频封面同策略)
116
+ const scrambledImgs = [item.avatar, item.cover, ...(item.extraCovers || []), ...item.images]
117
+ .filter(img => img?.kind === 'scrambled' && img.buffer);
118
+ if (scrambledImgs.length) {
119
+ const summary = buildImageHint(rt, scrambledImgs.length);
120
+ if (summary)
121
+ push([sender_1.el.text(summary)], true);
122
+ }
123
+ for (const img of scrambledImgs) {
124
+ const c = [];
125
+ if (img.token)
126
+ c.push(sender_1.el.text(`解混淆 ${img.token}`));
127
+ c.push(sender_1.el.imageBuffer(img.buffer, 'image/png'));
128
+ push(c, false);
129
+ }
130
+ return units;
131
+ }
132
+ /** 发送一组元素;失败时视频元素降级为链接文字 */
133
+ async function sendElements(sender, session, elements, quoteId) {
134
+ const withQuote = quoteId ? [sender_1.el.quote(quoteId), ...elements] : elements;
135
+ try {
136
+ await sender.send(session, withQuote);
137
+ }
138
+ catch {
139
+ const links = elements.map(sender_1.videoFallback);
140
+ await sender.send(session, quoteId ? [sender_1.el.quote(quoteId), ...links] : links).catch(() => { });
141
+ }
142
+ }
143
+ /**
144
+ * 单条整合:合并所有 mergeable 单元为一条消息(图片超限则退化为逐条),
145
+ * mergeable=false 单元独立逐条。
146
+ */
147
+ async function sendSingle(rt, sender, session, item, opts = {}) {
148
+ const units = await (0, hooks_1.runStage)(rt, 'compose', { item });
149
+ const mergeable = units.filter(u => u.mergeable);
150
+ const standalone = units.filter(u => !u.mergeable);
151
+ const maxImages = rt.config.singleSendMaxImages || 10;
152
+ let quote = opts.quoteId;
153
+ if (mergeable.length) {
154
+ const imgCount = mergeable.reduce((n, u) => n + u.content.filter(e => e.type === 'image').length, 0);
155
+ if (imgCount <= maxImages) {
156
+ const merged = mergeable.flatMap(u => u.content);
157
+ await sendElements(sender, session, merged, quote);
158
+ await (0, common_1.delay)(300);
159
+ quote = undefined; // 仅首条引用原消息
160
+ }
161
+ else {
162
+ for (const u of mergeable) {
163
+ await sendElements(sender, session, u.content, quote);
164
+ await (0, common_1.delay)(300);
165
+ quote = undefined;
166
+ }
167
+ }
168
+ }
169
+ for (const u of standalone) {
170
+ await sendElements(sender, session, u.content, quote);
171
+ await (0, common_1.delay)(300);
172
+ quote = undefined;
173
+ }
174
+ }
175
+ /** 逐条发送:每个语义单元一条消息 */
176
+ async function sendSplit(rt, sender, session, item, opts = {}) {
177
+ const units = await (0, hooks_1.runStage)(rt, 'compose', { item });
178
+ let quote = opts.quoteId;
179
+ for (const u of units) {
180
+ await sendElements(sender, session, u.content, quote);
181
+ await (0, common_1.delay)(300);
182
+ quote = undefined;
183
+ }
184
+ }
@@ -261,5 +261,14 @@ function engineConfigContributions() {
261
261
  { key: 'deduplicationTipText', type: 'string', default: '链接 ${url} 在最近 ${interval} 秒内已解析过,已跳过。', description: '重复解析提示,支持变量 ${url} ${interval}' },
262
262
  ],
263
263
  }),
264
+ (0, config_1.defineConfig)({
265
+ group: '通用链接解析(预留)',
266
+ fields: [
267
+ { key: 'genericParseEnabled', type: 'boolean', default: false, description: '对未命中平台声明的链接启用通用解析(预留:暂无实现,开启不生效)' },
268
+ { key: 'genericLlmBaseUrl', type: 'string', default: '', description: 'LLM 接口 Base URL(OpenAI 兼容格式;预留字段,暂不消费)' },
269
+ { key: 'genericLlmModel', type: 'string', default: '', description: 'LLM 模型名(预留字段,暂不消费)' },
270
+ { key: 'genericLlmApiKey', type: 'string', default: '', role: 'secret', description: 'LLM API Key(预留字段,暂不消费)' },
271
+ ],
272
+ }),
264
273
  ];
265
274
  }
@@ -16,6 +16,7 @@ const platform_config_1 = require("./platform-config");
16
16
  const translate_1 = require("./translate");
17
17
  const tls_client_1 = require("../utils/tls-client");
18
18
  const gateway_1 = require("./gateway");
19
+ const hooks_1 = require("../workflow/hooks");
19
20
  async function fetchApi(rt, url, type, fieldMapping, platformConf) {
20
21
  const { config, http, urlCacheLocal, proxyConfig, cacheTTL } = rt;
21
22
  const cacheKey = url;
@@ -53,7 +54,7 @@ async function fetchApi(rt, url, type, fieldMapping, platformConf) {
53
54
  parsed.lang = nativeTr.sourceLang;
54
55
  }
55
56
  if (!translated) {
56
- const generic = await rt.extensions.translate?.(rt, parsed.desc, target, parsed.lang);
57
+ const generic = await (0, hooks_1.runStage)(rt, 'translate', { text: parsed.desc, target, sourceLang: parsed.lang });
57
58
  if (generic) {
58
59
  translated = generic.text;
59
60
  provider = generic.provider;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 解析编排(发送层无关):三层去重 → 并发解析 → gate(NSFW 策略)预处理 → 发送分派。
3
+ *
4
+ * 发送由 OutboundSender 完成(Koishi 适配器见 src/sender/koishi-sender.ts)。
5
+ *
6
+ * 去重三层(默认全开):
7
+ * ① 同消息 URL 去重(含并发竞态)
8
+ * ② 内容指纹去重(不同链接相同内容只发首个)
9
+ * ③ title===desc 抑制(engine/parser 与 twitter mapper 内实现)
10
+ *
11
+ * 发送分派(优先级):
12
+ * enableForward(onebot/satori) → 合并转发
13
+ * sendStrategy='single'(默认)→ 单条整合;不可行/失败 → 转发 → split
14
+ * sendStrategy='split' → 逐条
15
+ */
16
+ import type { ParserRuntime } from './runtime';
17
+ import type { LinkMatch } from '../types';
18
+ import type { OutboundSender, SessionLike } from '../sender';
19
+ export interface FlushOptions {
20
+ /** 手动命令(parse <url>)显式触发:跳过去重检查(用户明确要求重新解析) */
21
+ skipDedup?: boolean;
22
+ /** 注入发送层;缺省取 host.sender */
23
+ sender?: OutboundSender;
24
+ }
25
+ export declare function flush(rt: ParserRuntime, session: SessionLike, matches: LinkMatch[], opts?: FlushOptions): Promise<void>;
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flush = flush;
4
+ const concurrency_1 = require("../utils/concurrency");
5
+ const logger_1 = require("../logger");
6
+ const common_1 = require("../utils/common");
7
+ const platform_config_1 = require("./platform-config");
8
+ const fetcher_1 = require("./fetcher");
9
+ const sender_1 = require("../sender");
10
+ const hooks_1 = require("../workflow/hooks");
11
+ /** 去重键按会话隔离:防的是同一会话内刷屏,不阻断内容跨会话传播 */
12
+ function dedupScopeKey(session, url) {
13
+ const scope = session?.channelId || session?.guildId || session?.userId || 'global';
14
+ return `${scope}::${url}`;
15
+ }
16
+ /** gate 预处理:把 ParsedData 转成发送层消费的 ProcessedItem */
17
+ async function processItem(rt, session, platform, text, parsed) {
18
+ // 各能力一律经阶段管道(基线=直通/不合并/不转换),扩展可注入或替换
19
+ const requesterId = String(session?.userId || 'unknown');
20
+ const images = [];
21
+ // 同源切图合并:四宫格/九宫格/n×n 宫格与水平/垂直切分条带识别为一张母图的分片,合成后发送;
22
+ // 识别失败 / ffmpeg 不可用 / 审核 fail-closed → 回退逐张处理
23
+ let mergedSent = false;
24
+ if (rt.config.mergeSameOriginImages !== false && (parsed.images || []).length >= 2) {
25
+ try {
26
+ const merged = await (0, hooks_1.runStage)(rt, 'merge', { urls: parsed.images });
27
+ if (merged) {
28
+ const outcome = await (0, hooks_1.runStage)(rt, 'media.merged', { platform, buffer: merged.buffer, refUrl: parsed.images[0] });
29
+ if (outcome) {
30
+ images.push(outcome);
31
+ mergedSent = true;
32
+ }
33
+ else
34
+ (0, logger_1.debugLog)('合并图未过内容安全策略,回退逐张处理');
35
+ }
36
+ }
37
+ catch (e) {
38
+ (0, logger_1.debugLog)(`切图合并跳过(${e?.message || e})`);
39
+ }
40
+ }
41
+ if (!mergedSent) {
42
+ for (const url of (parsed.images || [])) {
43
+ images.push(await (0, hooks_1.runStage)(rt, 'media.image', { platform, url, kind: 'image' }));
44
+ }
45
+ }
46
+ const avatar = parsed.avatar ? await (0, hooks_1.runStage)(rt, 'media.image', { platform, url: parsed.avatar, kind: 'avatar' }) : { kind: 'drop' };
47
+ const cover = (parsed.cover && parsed.type !== 'image' && parsed.type !== 'live_photo' && parsed.type !== 'live')
48
+ ? await (0, hooks_1.runStage)(rt, 'media.image', { platform, url: parsed.cover, kind: 'cover' })
49
+ : null;
50
+ const video = parsed.video
51
+ ? await (0, hooks_1.runStage)(rt, 'media.video', { platform, videoUrl: parsed.video, coverUrl: parsed.cover || '', meta: { title: parsed.title, author: parsed.author, requesterId } })
52
+ : { kind: 'raw', url: '' };
53
+ // 推文动图:按配置转 GIF(仅合规视频;转换失败回退原视频)
54
+ let gif = null;
55
+ if (parsed.isGif && video.kind === 'raw' && video.url && rt.config.gifConvertEnabled !== false) {
56
+ gif = await (0, hooks_1.runStage)(rt, 'transcode', {
57
+ url: video.url, durationSec: parsed.duration,
58
+ opts: {
59
+ maxWidth: rt.config.gifMaxWidth || 480,
60
+ fps: rt.config.gifFps || 15,
61
+ maxDurationSec: rt.config.gifMaxDurationSec || 15,
62
+ },
63
+ });
64
+ }
65
+ // 多视频推文:其余视频逐条过 gate 与 GIF 转换;封面单独审核、随行展示,视频判定用各自封面
66
+ const extraVideos = [];
67
+ const extraGifs = [];
68
+ const extraCovers = [];
69
+ for (const ev of parsed.extraVideos || []) {
70
+ const evCover = ev.cover || parsed.cover || '';
71
+ extraCovers.push(evCover
72
+ ? await (0, hooks_1.runStage)(rt, 'media.image', { platform, url: evCover, kind: 'cover' })
73
+ : null);
74
+ const outcome = await (0, hooks_1.runStage)(rt, 'media.video', { platform, videoUrl: ev.url, coverUrl: evCover, meta: { title: parsed.title, author: parsed.author, requesterId } });
75
+ extraVideos.push(outcome);
76
+ if (ev.isGif && outcome.kind === 'raw' && outcome.url && rt.config.gifConvertEnabled !== false) {
77
+ extraGifs.push(await (0, hooks_1.runStage)(rt, 'transcode', {
78
+ url: outcome.url, durationSec: ev.duration || 0,
79
+ opts: {
80
+ maxWidth: rt.config.gifMaxWidth || 480,
81
+ fps: rt.config.gifFps || 15,
82
+ maxDurationSec: rt.config.gifMaxDurationSec || 15,
83
+ },
84
+ }));
85
+ }
86
+ else {
87
+ extraGifs.push(null);
88
+ }
89
+ }
90
+ return { text, parsed, images, avatar, cover, video, gif, extraVideos, extraGifs, extraCovers };
91
+ }
92
+ async function flush(rt, session, matches, opts = {}) {
93
+ const sender = opts.sender ?? rt.host.sender;
94
+ if (!sender)
95
+ throw new Error('flush: 未提供 OutboundSender(请通过 opts.sender 或 host.sender 注入)');
96
+ const { config, dedupCache, contentDedupCache } = rt;
97
+ (0, logger_1.debugLog)(`开始解析 ${matches.length} 个链接`);
98
+ const items = [];
99
+ const errors = [];
100
+ const limiter = new concurrency_1.ConcurrencyLimiter(config.maxConcurrent || 3);
101
+ // 去重层①:同消息 URL 去重(保序)
102
+ const seenUrl = new Set();
103
+ const uniqueMatches = matches.filter((m) => {
104
+ const key = m.url.replace(/\/+$/, '');
105
+ if (seenUrl.has(key))
106
+ return false;
107
+ seenUrl.add(key);
108
+ return true;
109
+ });
110
+ const promises = uniqueMatches.map(async (match) => {
111
+ await limiter.acquire();
112
+ try {
113
+ const platformEnabled = config.platformEnabled?.[match.type] ?? true;
114
+ if (!platformEnabled && !match.type.startsWith('custom_')) {
115
+ (0, logger_1.debugLog)(`平台 ${match.type} 已禁用,跳过链接: ${match.url}`);
116
+ return;
117
+ }
118
+ const dedupEnabled = !opts.skipDedup && config.enableDeduplication !== false && config.deduplicationInterval > 0;
119
+ const scopeKey = dedupScopeKey(session, match.url);
120
+ if (dedupEnabled) {
121
+ const lastTime = dedupCache.get(scopeKey);
122
+ if (lastTime && (Date.now() - lastTime < config.deduplicationInterval * 1000)) {
123
+ (0, logger_1.debugLog)(`跳过重复链接: ${match.url}`);
124
+ const shortUrl = match.url.length > 80 ? match.url.slice(0, 80) + '...' : match.url;
125
+ const tip = (0, common_1.getText)(config, 'deduplicationTipText').replace(/\$\{url\}/g, shortUrl).replace(/\$\{interval\}/g, String(config.deduplicationInterval));
126
+ await sender.send(session, [sender_1.el.text(tip)]).catch(() => { });
127
+ return;
128
+ }
129
+ }
130
+ logger_1.logger.info(`解析链接: ${match.url} (${match.type})`);
131
+ const platformConf = (0, platform_config_1.getPlatformConfig)(rt, match.type);
132
+ const result = await (0, fetcher_1.processSingleUrl)(rt, match.url, match.type, platformConf.fieldMapping, platformConf);
133
+ if (result.success) {
134
+ if (dedupEnabled) {
135
+ // 去重层②:内容指纹按会话隔离;不同链接相同内容只发首个
136
+ const fp = dedupScopeKey(session, (0, common_1.contentFingerprint)(result.data.parsed));
137
+ const lastDedup = contentDedupCache.get(fp);
138
+ if (lastDedup && (Date.now() - lastDedup < config.deduplicationInterval * 1000)) {
139
+ (0, logger_1.debugLog)(`跳过重复内容: ${match.url}`);
140
+ return;
141
+ }
142
+ contentDedupCache.set(fp, Date.now());
143
+ dedupCache.set(scopeKey, Date.now());
144
+ }
145
+ items.push(await processItem(rt, session, match.type, result.data.text, result.data.parsed));
146
+ }
147
+ else {
148
+ const displayUrl = match.url.length > 80 ? match.url.slice(0, 80) + '...' : match.url;
149
+ const item = (0, common_1.getText)(config, 'parseErrorItemFormat').replace(/\$\{url\}/g, displayUrl).replace(/\$\{msg\}/g, result.msg);
150
+ errors.push(item);
151
+ }
152
+ }
153
+ finally {
154
+ limiter.release();
155
+ }
156
+ });
157
+ await Promise.all(promises);
158
+ if (errors.length)
159
+ await sender.send(session, [sender_1.el.text(`${(0, common_1.getText)(config, 'parseErrorPrefix')}\n${errors.join('\n')}`)]);
160
+ if (!items.length)
161
+ return;
162
+ await (0, hooks_1.runStage)(rt, 'send', { items, session, sender, quoteId: session?.messageId });
163
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 合并转发模式:把每个语义单元(MessageUnit)构建为一个转发气泡。
3
+ *
4
+ * 发送层无关:构建 ForwardBubble[],由 OutboundSender.sendForward 翻译为
5
+ * 宿主实现(Koishi 为 <message forward> 嵌套 <message><author>)。
6
+ * 仅一个气泡时不打包卡片,直接发送。
7
+ */
8
+ import type { ParserRuntime } from './runtime';
9
+ import type { OutboundSender, SessionLike } from '../sender';
10
+ import type { ProcessedItem } from './compose';
11
+ export declare function buildForwardNode(_session: any, content: any, botName: string): {
12
+ content: any;
13
+ name: string;
14
+ };
15
+ export declare function sendForward(rt: ParserRuntime, sender: OutboundSender, session: SessionLike, items: ProcessedItem[]): Promise<void>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildForwardNode = buildForwardNode;
4
+ exports.sendForward = sendForward;
5
+ const common_1 = require("../utils/common");
6
+ const logger_1 = require("../logger");
7
+ const hooks_1 = require("../workflow/hooks");
8
+ // 保持兼容:旧签名 buildForwardNode(session, content, botName) 曾用于构建气泡;
9
+ // 发送层无关后由 OutboundSender.sendForward 负责,占位导出避免外部引用断裂。
10
+ function buildForwardNode(_session, content, botName) {
11
+ return { content, name: botName };
12
+ }
13
+ async function sendForward(rt, sender, session, items) {
14
+ const { config } = rt;
15
+ const botName = config.botName || '视频解析机器人';
16
+ const bubbles = [];
17
+ const total = items.length;
18
+ for (let i = 0; i < total; i++) {
19
+ const item = items[i];
20
+ const units = await (0, hooks_1.runStage)(rt, 'compose', { item });
21
+ // 多 item 时给概述文字加序号前缀
22
+ if (total > 1) {
23
+ for (const u of units) {
24
+ const txt = u.content.find((e) => e.type === 'text');
25
+ if (txt && txt.type === 'text') {
26
+ txt.text = `【${i + 1}/${total}】\n${txt.text}`;
27
+ break;
28
+ }
29
+ }
30
+ }
31
+ // mergeable 单元(概述+图片+提示)合并为第一个气泡;非 mergeable(视频文件/取件码/混淆图)各占一个气泡
32
+ const mergeable = units.filter(u => u.mergeable);
33
+ const standalone = units.filter(u => !u.mergeable);
34
+ if (mergeable.length)
35
+ bubbles.push({ content: mergeable.flatMap(u => u.content), name: botName });
36
+ for (const u of standalone)
37
+ bubbles.push({ content: u.content, name: botName });
38
+ }
39
+ // 仅一条消息:不打包转发卡片,直接发送
40
+ if (bubbles.length === 1) {
41
+ await sender.send(session, bubbles[0].content, config.retryTimes);
42
+ return;
43
+ }
44
+ const MAX_BUBBLES = 50;
45
+ for (let i = 0; i < bubbles.length; i += MAX_BUBBLES) {
46
+ const batch = bubbles.slice(i, i + MAX_BUBBLES);
47
+ try {
48
+ await sender.sendForward(session, batch, config.retryTimes);
49
+ }
50
+ catch (err) {
51
+ logger_1.logger.error('合并转发失败,降级逐条发送:', err);
52
+ for (const item of items) {
53
+ for (const u of await (0, hooks_1.runStage)(rt, 'compose', { item })) {
54
+ await sender.send(session, u.content).catch(() => { });
55
+ await (0, common_1.delay)(300);
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 通用链接解析(预留,暂不实现):对未命中任何平台声明的链接的基础解析能力。
3
+ *
4
+ * 设计:
5
+ * - 经 'parse.generic' 工作流阶段调度(见 workflow/hooks.ts);
6
+ * - 基线实现 = 未实现(返回 null)——链路保持现状:非平台链接不进入解析流程;
7
+ * - 后续实现可由宿主或扩展 replace('parse.generic') 注入
8
+ * (OG 元信息抽取 / 页面结构化 / LLM 归纳,source 字段标注来源);
9
+ * - LLM 接口仅预留契约:engineConfigContributions() 的「通用链接解析(预留)」组
10
+ * 提供 baseUrl/model/apiKey 配置位,暂无消费方,开关默认关闭。
11
+ */
12
+ export interface GenericLinkInput {
13
+ url: string;
14
+ }
15
+ export interface GenericLinkResult {
16
+ title?: string;
17
+ desc?: string;
18
+ images: string[];
19
+ /** 解析来源:og=OG 元信息 / html=页面结构化 / llm=模型归纳 */
20
+ source?: 'og' | 'html' | 'llm';
21
+ }
22
+ /** LLM 增强预留接口(字段与「通用链接解析(预留)」配置组一一对应) */
23
+ export interface LlmOptions {
24
+ baseUrl?: string;
25
+ apiKey?: string;
26
+ model?: string;
27
+ }
28
+ /** 通用解析器实现契约(replace('parse.generic') 的注入形态) */
29
+ export interface GenericLinkParser {
30
+ parse(input: GenericLinkInput, rt: any): Promise<GenericLinkResult | null>;
31
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ /**
3
+ * 通用链接解析(预留,暂不实现):对未命中任何平台声明的链接的基础解析能力。
4
+ *
5
+ * 设计:
6
+ * - 经 'parse.generic' 工作流阶段调度(见 workflow/hooks.ts);
7
+ * - 基线实现 = 未实现(返回 null)——链路保持现状:非平台链接不进入解析流程;
8
+ * - 后续实现可由宿主或扩展 replace('parse.generic') 注入
9
+ * (OG 元信息抽取 / 页面结构化 / LLM 归纳,source 字段标注来源);
10
+ * - LLM 接口仅预留契约:engineConfigContributions() 的「通用链接解析(预留)」组
11
+ * 提供 baseUrl/model/apiKey 配置位,暂无消费方,开关默认关闭。
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,23 +1,24 @@
1
1
  /**
2
- * 运行时构建(自宿主 runtime 下沉,平台无关)。
2
+ * 运行时装配(本文件在 core 内,平台无关)。
3
3
  *
4
- * 平台定义列表(defs)由调用方传入:
5
- * - koishi 兼容层传内置 BUILTIN_PLATFORMS(本地 definitions)
6
- * - CLI 传 collectPlatformDefinitions()(已安装的 @sns-parse/platform-*)
7
- * 默认扩展实现同样由宿主注入(loadExtensionImplementations() 发现已安装 ext-*);
8
- * core 只负责调度,不持有任何功能实现。
4
+ * 平台定义列表(defs)由宿主按已加载平台插件声明注入:
5
+ * - koishi 兼容层:collectPlatformDefinitions(anchor)(聚合包 + 粒度包并集)
6
+ * - CLI:collectPlatformDefinitions()
7
+ * 工作流阶段基线在 createDefaultPipeline(),扩展经 WorkflowExtension.setup(hooks) 注入/替换。
8
+ * core 不依赖任何扩展包、不含扩展实现。
9
9
  */
10
10
  import { AxiosInstance } from 'axios';
11
11
  import type { ParsedData, CustomPlatformConfig } from '../types';
12
12
  import type { VideoParserHost } from '../host';
13
- import type { VideoParserExtensions } from '../extensions';
13
+ import type { WorkflowExtension } from '../extensions';
14
14
  import type { PlatformDefinition } from '../platform';
15
15
  import { SimpleLRUCache } from '../utils/cache';
16
16
  import { type DedicatedApiMaps } from './gateway';
17
+ import type { Pipeline } from '../workflow/hooks';
17
18
  export interface ParserRuntime {
18
- /** 宿主抽象(core 统一入口) */
19
+ /** 宿主适配(core 统一入口) */
19
20
  host: VideoParserHost;
20
- /** 过渡期兼容字段:底层上下文(等同于 host.context) */
21
+ /** 仅在特化字段:底层运行模块(等同于 host.context) */
21
22
  ctx: any;
22
23
  config: any;
23
24
  http: AxiosInstance;
@@ -34,17 +35,19 @@ export interface ParserRuntime {
34
35
  pattern: RegExp;
35
36
  type: string;
36
37
  }[];
37
- /** 平台定义列表(原生解析/翻译钩子由 fetcher 查找使用) */
38
+ /** 平台定义列表(原生解析/原生翻译 fetcher 阶段使用) */
38
39
  defs: PlatformDefinition[];
39
- /** 平台专属端点映射(由 defs 计算;getPlatformConfig 使用) */
40
+ /** 平台专属 API 映射(由 defs 推导;getPlatformConfig 使用) */
40
41
  dedicatedApis: DedicatedApiMaps;
41
- /** 扩展能力(默认实现 + 宿主覆盖) */
42
- extensions: VideoParserExtensions;
42
+ /** 已加载扩展片段(能力位/配置贡献查询用;实现经管道注入) */
43
+ extensions: WorkflowExtension[];
44
+ /** 工作流阶段管道(基线 + 扩展注入) */
45
+ pipeline: Pipeline;
43
46
  }
44
47
  export interface CreateRuntimeOptions {
45
- /** 平台定义列表(内置或已安装包收集);缺省为空(仅自定义平台规则可用) */
48
+ /** 平台定义列表(按用户已加载插件声明;缺省为空,只认自定义平台配置) */
46
49
  defs?: PlatformDefinition[];
47
- /** 默认扩展实现(koishi 层含 NSFW;CLI 可用 createCoreExtensions) */
48
- defaultExtensions?: VideoParserExtensions;
50
+ /** 扩展片段(setup 钩子注入;后注册者胜) */
51
+ extensions?: WorkflowExtension[];
49
52
  }
50
53
  export declare function createRuntime(source: any, config: any, opts?: CreateRuntimeOptions): ParserRuntime;