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

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
+ }
@@ -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
+ }
@@ -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;
@@ -5,13 +5,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createRuntime = createRuntime;
7
7
  /**
8
- * 运行时构建(自宿主 runtime 下沉,平台无关)。
8
+ * 运行时装配(本文件在 core 内,平台无关)。
9
9
  *
10
- * 平台定义列表(defs)由调用方传入:
11
- * - koishi 兼容层传内置 BUILTIN_PLATFORMS(本地 definitions)
12
- * - CLI 传 collectPlatformDefinitions()(已安装的 @sns-parse/platform-*)
13
- * 默认扩展实现同样由宿主注入(loadExtensionImplementations() 发现已安装 ext-*);
14
- * core 只负责调度,不持有任何功能实现。
10
+ * 平台定义列表(defs)由宿主按已加载平台插件声明注入:
11
+ * - koishi 兼容层:collectPlatformDefinitions(anchor)(聚合包 + 粒度包并集)
12
+ * - CLI:collectPlatformDefinitions()
13
+ * 工作流阶段基线在 createDefaultPipeline(),扩展经 WorkflowExtension.setup(hooks) 注入/替换。
14
+ * core 不依赖任何扩展包、不含扩展实现。
15
15
  */
16
16
  const axios_1 = __importDefault(require("axios"));
17
17
  const https_proxy_agent_1 = require("https-proxy-agent");
@@ -20,10 +20,11 @@ const cache_1 = require("../utils/cache");
20
20
  const field_mapping_1 = require("../utils/field-mapping");
21
21
  const platform_config_1 = require("./platform-config");
22
22
  const gateway_1 = require("./gateway");
23
+ const default_1 = require("../workflow/default");
23
24
  function createRuntime(source, config, opts = {}) {
24
25
  const host = (0, host_1.createHost)(source);
25
26
  const ctx = host.context;
26
- const extensions = { ...(opts.defaultExtensions || {}), ...(host.extensions || {}) };
27
+ const extensions = [...(opts.extensions || []), ...(host.extensions || [])];
27
28
  const dedupCache = new cache_1.SimpleLRUCache(1000, config.deduplicationInterval * 1000);
28
29
  const cacheTTL = (config.cacheTTL || 600) * 1000;
29
30
  const urlCacheLocal = new cache_1.SimpleLRUCache(500, cacheTTL);
@@ -61,7 +62,12 @@ function createRuntime(source, config, opts = {}) {
61
62
  axiosConfig.proxy = false;
62
63
  }
63
64
  const http = axios_1.default.create(axiosConfig);
64
- return {
65
+ const pipeline = (0, default_1.createDefaultPipeline)(null);
66
+ for (const ext of extensions) {
67
+ if (ext && typeof ext.setup === 'function')
68
+ ext.setup(pipeline);
69
+ }
70
+ const runtime = {
65
71
  host,
66
72
  ctx,
67
73
  config,
@@ -76,5 +82,8 @@ function createRuntime(source, config, opts = {}) {
76
82
  defs,
77
83
  dedicatedApis,
78
84
  extensions,
85
+ pipeline,
79
86
  };
87
+ pipeline.rt = runtime;
88
+ return runtime;
80
89
  }
@@ -1,21 +1,25 @@
1
1
  /**
2
- * 扩展契约:core 不直接依赖 NSFW/合并/翻译/GIF 实现,而是通过可注入的
3
- * VideoParserExtensions 调用。宿主可整体替换或按需覆盖单项能力。
2
+ * 扩展契约:core 完整工作流 + 基线行为;扩展经阶段钩子注入/修改。
4
3
  *
5
- * 默认实现由 extensions 包(@sns-parse/extensions)提供;runtime 会合并默认实现,
6
- * 因此 core 编排层可直接调用,无需判空。
4
+ * core 不内建任何扩展实现(NSFW/合并/翻译/GIF),也不依赖扩展包;
5
+ * 基线行为保证无扩展时全链路可跑(媒体直通、不合并逐张发、GIF 退化发视频、不翻译)。
6
+ * 扩展导出 WorkflowExtension(含 setup(hooks)),由宿主收集后注入管道。
7
7
  */
8
+ import type { ConfigContribution } from './config';
8
9
  import type { ParserRuntimeLike } from './host';
10
+ import type { WorkflowHooks } from './workflow/hooks';
9
11
  export type MediaKind = 'cover' | 'image' | 'avatar' | 'music-cover';
10
12
  export interface ImageOutcome {
11
- /** raw=原图 url;scrambled=混淆 buffer+token;link=仅链接文字;drop=不发送 */
13
+ /** raw=原图 url 或 buffer 直发;scrambled=混淆 buffer+token;link=只发链接文本;drop=丢弃 */
12
14
  kind: 'raw' | 'scrambled' | 'link' | 'drop';
13
15
  url?: string;
14
16
  buffer?: Buffer;
15
17
  token?: string;
18
+ /** buffer 直发时的 mime(缺省 image/jpeg) */
19
+ mime?: string;
16
20
  }
17
21
  export interface VideoOutcome {
18
- /** raw=照发;card=群内纯文字卡片+token(无封面无视频);link=文字卡片+原链接;drop=仅文字卡片 */
22
+ /** raw=直发视频;card=群内容易吞 → 暂存+token,无法直发视频;link=只发卡片+原链接;drop=只发卡片 */
19
23
  kind: 'raw' | 'card' | 'link' | 'drop';
20
24
  url?: string;
21
25
  token?: string;
@@ -29,29 +33,23 @@ export interface GifOptions {
29
33
  fps: number;
30
34
  maxDurationSec: number;
31
35
  }
32
- /** 扩展能力集合(全部可选;runtime 会与默认实现合并) */
33
- export interface VideoParserExtensions {
34
- /** 同源切图合并;返回 null 表示不合并 */
35
- mergeImages?(rt: ParserRuntimeLike, urls: string[]): Promise<{
36
- buffer: Buffer;
37
- } | null>;
38
- /** 单张出站图片处理(NSFW 策略) */
39
- processImage?(rt: ParserRuntimeLike, platform: string, url: string, kind: MediaKind): Promise<ImageOutcome>;
40
- /** 出站视频处理(NSFW 策略) */
41
- processVideo?(rt: ParserRuntimeLike, platform: string, videoUrl: string, coverUrl: string, meta: {
42
- title?: string;
43
- author?: string;
44
- requesterId: string;
45
- }): Promise<VideoOutcome>;
46
- /** 合并图审核;返回 null 表示回退逐张处理 */
47
- processMergedImage?(rt: ParserRuntimeLike, platform: string, buffer: Buffer, refUrl: string): Promise<ImageOutcome | null>;
48
- /** 视频转 GIF;失败返回 null 回退原视频 */
49
- mp4ToGif?(rt: ParserRuntimeLike, url: string, durationSec: number, opts: GifOptions): Promise<Buffer | null>;
50
- /** 文本翻译;失败返回 null */
51
- translate?(rt: ParserRuntimeLike, text: string, target: string, sourceLang?: string): Promise<TranslateResult | null>;
52
- /** 内容安全能力状态(启动日志) */
36
+ export interface VideoMeta {
37
+ title?: string;
38
+ author?: string;
39
+ requesterId: string;
40
+ }
41
+ /** 单个扩展片段:能力注册(setup 钩子注入)+ 可选配置贡献/能力位 */
42
+ export interface WorkflowExtension {
43
+ name: string;
44
+ setup(hooks: WorkflowHooks): void;
45
+ configContribution?: ConfigContribution;
53
46
  capability?(rt: ParserRuntimeLike): {
54
47
  ferret: boolean;
55
48
  moderation: string | null;
56
49
  };
57
50
  }
51
+ /** 汇总各扩展能力位(日志用) */
52
+ export declare function collectCapabilities(extensions: WorkflowExtension[] | undefined, rt: ParserRuntimeLike): {
53
+ ferret: boolean;
54
+ moderation: string | null;
55
+ };
package/lib/extensions.js CHANGED
@@ -1,2 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectCapabilities = collectCapabilities;
4
+ /** 汇总各扩展能力位(日志用) */
5
+ function collectCapabilities(extensions, rt) {
6
+ let ferret = false;
7
+ let moderation = null;
8
+ for (const e of extensions || []) {
9
+ if (typeof e.capability !== 'function')
10
+ continue;
11
+ try {
12
+ const c = e.capability(rt);
13
+ if (c?.ferret)
14
+ ferret = true;
15
+ if (c?.moderation)
16
+ moderation = c.moderation;
17
+ }
18
+ catch {
19
+ // 能力位探测失败不影响主链路
20
+ }
21
+ }
22
+ return { ferret, moderation };
23
+ }
package/lib/host.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { type LoggerLike } from './logger';
11
11
  import type { OutboundSender } from './sender';
12
- import type { VideoParserExtensions } from './extensions';
12
+ import type { WorkflowExtension } from './extensions';
13
13
  export interface VideoParserHost {
14
14
  logger: LoggerLike;
15
15
  /** 数据根目录(如 koishi ctx.baseDir);缺省时持久化走临时目录 */
@@ -19,7 +19,7 @@ export interface VideoParserHost {
19
19
  /** 发送层实现(缺省时由宿主适配层注入) */
20
20
  sender?: OutboundSender;
21
21
  /** 扩展实现(部分覆盖;缺省能力由内置实现补齐) */
22
- extensions?: VideoParserExtensions;
22
+ extensions?: WorkflowExtension[];
23
23
  /** 过渡期:底层上下文(koishi Context 等),core 新代码不应依赖 */
24
24
  context?: any;
25
25
  }
@@ -31,7 +31,7 @@ export interface ParserRuntimeLike {
31
31
  config: any;
32
32
  http: any;
33
33
  host: VideoParserHost;
34
- extensions: VideoParserExtensions;
34
+ extensions: WorkflowExtension[];
35
35
  [key: string]: any;
36
36
  }
37
37
  /**
package/lib/index.d.ts CHANGED
@@ -22,6 +22,11 @@ export * from './engine/platform-config';
22
22
  export * from './engine/translate';
23
23
  export * from './engine/parser';
24
24
  export * from './engine/fetcher';
25
+ export * from './engine/compose';
26
+ export * from './engine/forward';
27
+ export * from './engine/flush';
25
28
  export * from './engine/runtime';
26
29
  export * from './engine/config-io';
30
+ export * from './workflow/hooks';
31
+ export * from './workflow/default';
27
32
  export * from './registry';
package/lib/index.js CHANGED
@@ -38,6 +38,11 @@ __exportStar(require("./engine/platform-config"), exports);
38
38
  __exportStar(require("./engine/translate"), exports);
39
39
  __exportStar(require("./engine/parser"), exports);
40
40
  __exportStar(require("./engine/fetcher"), exports);
41
+ __exportStar(require("./engine/compose"), exports);
42
+ __exportStar(require("./engine/forward"), exports);
43
+ __exportStar(require("./engine/flush"), exports);
41
44
  __exportStar(require("./engine/runtime"), exports);
42
45
  __exportStar(require("./engine/config-io"), exports);
46
+ __exportStar(require("./workflow/hooks"), exports);
47
+ __exportStar(require("./workflow/default"), exports);
43
48
  __exportStar(require("./registry"), exports);
package/lib/registry.d.ts CHANGED
@@ -1,20 +1,28 @@
1
+ /**
2
+ * 已安装碎片包(平台 @sns-parse/platform-* / 扩展 @sns-parse/ext-*)的动态发现。
3
+ *
4
+ * - 平台:先聚合包(@sns-parse/platforms 的 definitions[]),再逐个候选粒度包,
5
+ * 按 type 去重合并(粒度包覆盖聚合条目);宿主可传自己的 require 锚点
6
+ * (koishi/CLI 包路径),pnpm strict 下也能发现宿主直依赖。
7
+ * - 扩展:loadWorkflowExtensions() 返回 WorkflowExtension[](含 setup 钩子),
8
+ * 未安装的自动缺席(能力/配置贡献同时缺席,不报错)。
9
+ */
10
+ import { createRequire } from 'node:module';
1
11
  import type { PlatformDefinition } from './platform';
2
12
  import type { ConfigContribution } from './config';
3
- import type { VideoParserExtensions } from './extensions';
4
- /** 收集已安装平台的定义(含 label/rules/dedicated) */
5
- export declare function collectPlatformDefinitions(): PlatformDefinition[];
6
- /** 已安装平台的链接规则(扁平) */
7
- export declare function collectPlatformLinkRules(): {
13
+ import type { WorkflowExtension } from './extensions';
14
+ type NodeRequire = ReturnType<typeof createRequire>;
15
+ /** 收集已加载平台的定义(聚合包 + 粒度包并集;粒度包覆盖聚合条目) */
16
+ export declare function collectPlatformDefinitions(anchor?: NodeRequire): PlatformDefinition[];
17
+ /** 已加载平台的链接规则(按平台声明) */
18
+ export declare function collectPlatformLinkRules(anchor?: NodeRequire): {
8
19
  pattern: RegExp;
9
20
  type: string;
10
21
  }[];
11
- export interface LoadedExtension {
12
- name: string;
13
- contribution?: ConfigContribution;
14
- extension?: Partial<VideoParserExtensions>;
15
- }
16
- /** 探测并加载全部已安装扩展 */
17
- export declare function loadExtensions(): LoadedExtension[];
18
- export declare function loadExtensionContributions(): ConfigContribution[];
19
- /** 合并所有已安装扩展的能力片段 */
20
- export declare function loadExtensionImplementations(): Partial<VideoParserExtensions>;
22
+ /** 探测并实例化全部已安装扩展(WorkflowExtension[];含 setup 钩子) */
23
+ export declare function loadWorkflowExtensions(anchor?: NodeRequire): WorkflowExtension[];
24
+ /** 已安装扩展的配置贡献(settings 动态渲染用) */
25
+ export declare function loadExtensionContributions(anchor?: NodeRequire): ConfigContribution[];
26
+ /** 兼容旧名(已废弃:请用 loadWorkflowExtensions) */
27
+ export declare function loadExtensionImplementations(anchor?: NodeRequire): WorkflowExtension[];
28
+ export {};
package/lib/registry.js CHANGED
@@ -2,76 +2,100 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.collectPlatformDefinitions = collectPlatformDefinitions;
4
4
  exports.collectPlatformLinkRules = collectPlatformLinkRules;
5
- exports.loadExtensions = loadExtensions;
5
+ exports.loadWorkflowExtensions = loadWorkflowExtensions;
6
6
  exports.loadExtensionContributions = loadExtensionContributions;
7
7
  exports.loadExtensionImplementations = loadExtensionImplementations;
8
8
  /**
9
- * 已安装碎片包的动态收集(平台 @sns-parse/platform-* 与扩展 @sns-parse/ext-*)。
9
+ * 已安装碎片包(平台 @sns-parse/platform-* / 扩展 @sns-parse/ext-*)的动态发现。
10
10
  *
11
- * - 可自选安装单个碎片包;安装聚合包(@sns-parse/platforms / extensions)则全部到位
12
- * - 未安装的碎片自动缺席(链接识别、配置项与能力同时缺席)
13
- * - koishi 兼容层与 CLI 均经此处收集定义与配置声明
11
+ * - 平台:先聚合包(@sns-parse/platforms 的 definitions[]),再逐个候选粒度包,
12
+ * 按 type 去重合并(粒度包覆盖聚合条目);宿主可传自己的 require 锚点
13
+ * (koishi/CLI 包路径),pnpm strict 下也能发现宿主直依赖。
14
+ * - 扩展:loadWorkflowExtensions() 返回 WorkflowExtension[](含 setup 钩子),
15
+ * 未安装的自动缺席(能力/配置贡献同时缺席,不报错)。
14
16
  */
15
17
  const node_module_1 = require("node:module");
16
- const nodeRequire = (0, node_module_1.createRequire)(typeof __filename !== 'undefined' ? __filename : process.cwd() + '/');
18
+ const fallbackRequire = (0, node_module_1.createRequire)(typeof __filename !== 'undefined' ? __filename : process.cwd() + '/');
19
+ function req(anchor) {
20
+ return anchor || fallbackRequire;
21
+ }
17
22
  const PLATFORM_TYPES = [
18
23
  'bilibili', 'douyin', 'kuaishou', 'xiaohongshu', 'weibo', 'xigua', 'youtube',
19
24
  'tiktok', 'acfun', 'zhihu', 'weishi', 'huya', 'haokan', 'meipai', 'twitter',
20
25
  'instagram', 'doubao', 'doubao_image', 'jimeng', 'oasis', 'wechat_channel',
21
26
  'lishi', 'quanmin', 'pipigx', 'pipixia', 'zuiyou', 'toutiao',
22
27
  ];
23
- /** 收集已安装平台的定义(含 label/rules/dedicated) */
24
- function collectPlatformDefinitions() {
25
- const out = [];
28
+ /** 收集已加载平台的定义(聚合包 + 粒度包并集;粒度包覆盖聚合条目) */
29
+ function collectPlatformDefinitions(anchor) {
30
+ const nodeRequire = req(anchor);
31
+ const byType = new Map();
32
+ // 1) 聚合包(若安装):一次拿全声明
33
+ try {
34
+ const agg = nodeRequire('@sns-parse/platforms');
35
+ const defs = agg?.definitions || agg?.default?.definitions || [];
36
+ for (const d of defs) {
37
+ if (d && d.type && !byType.has(d.type))
38
+ byType.set(d.type, d);
39
+ }
40
+ }
41
+ catch {
42
+ // 未装聚合包 → 只看粒度包
43
+ }
44
+ // 2) 粒度包(可覆盖聚合条目)
26
45
  for (const type of PLATFORM_TYPES) {
27
46
  try {
28
47
  const mod = nodeRequire(`@sns-parse/platform-${type}`);
29
48
  const def = mod.default || mod[type];
30
49
  if (def && def.type)
31
- out.push(def);
50
+ byType.set(def.type, def);
32
51
  }
33
52
  catch {
34
- // 未安装该平台包 → 跳过
53
+ // 未装该平台 → 缺席
35
54
  }
36
55
  }
37
- return out;
56
+ return [...byType.values()];
38
57
  }
39
- /** 已安装平台的链接规则(扁平) */
40
- function collectPlatformLinkRules() {
41
- return collectPlatformDefinitions().flatMap(d => d.rules.map(pattern => ({ pattern, type: d.type })));
58
+ /** 已加载平台的链接规则(按平台声明) */
59
+ function collectPlatformLinkRules(anchor) {
60
+ return collectPlatformDefinitions(anchor).flatMap(d => d.rules.map(pattern => ({ pattern, type: d.type })));
42
61
  }
43
62
  const EXTENSION_SPECS = [
44
- { name: 'ext-nsfw', contributionKey: 'nsfwConfigContribution', extensionKey: 'nsfwExtension' },
63
+ { name: 'ext-nsfw', extensionKey: 'nsfwExtension', contributionKey: 'nsfwConfigContribution' },
45
64
  { name: 'ext-merge', extensionKey: 'mergeExtension' },
46
65
  { name: 'ext-translate', extensionKey: 'translateExtension' },
47
66
  { name: 'ext-gif', extensionKey: 'gifExtension' },
48
67
  ];
49
- /** 探测并加载全部已安装扩展 */
50
- function loadExtensions() {
68
+ /** 探测并实例化全部已安装扩展(WorkflowExtension[];含 setup 钩子) */
69
+ function loadWorkflowExtensions(anchor) {
70
+ const nodeRequire = req(anchor);
51
71
  const out = [];
52
72
  for (const spec of EXTENSION_SPECS) {
53
73
  try {
54
74
  const mod = nodeRequire(`@sns-parse/${spec.name}`);
55
- out.push({
56
- name: spec.name,
57
- contribution: spec.contributionKey ? mod[spec.contributionKey] : undefined,
58
- extension: spec.extensionKey && typeof mod[spec.extensionKey] === 'function'
59
- ? mod[spec.extensionKey]()
60
- : undefined,
61
- });
75
+ const factory = mod?.[spec.extensionKey];
76
+ if (typeof factory !== 'function')
77
+ continue;
78
+ const ext = factory();
79
+ if (!ext || typeof ext.setup !== 'function')
80
+ continue;
81
+ if (!ext.configContribution && spec.contributionKey && mod[spec.contributionKey]) {
82
+ ext.configContribution = mod[spec.contributionKey];
83
+ }
84
+ out.push(ext);
62
85
  }
63
86
  catch {
64
- // 未安装该扩展 → 跳过(配置项与能力同时缺席)
87
+ // 未安装的扩展 → 缺席(能力与配置贡献同时缺席)
65
88
  }
66
89
  }
67
90
  return out;
68
91
  }
69
- function loadExtensionContributions() {
70
- return loadExtensions()
71
- .map(e => e.contribution)
92
+ /** 已安装扩展的配置贡献(settings 动态渲染用) */
93
+ function loadExtensionContributions(anchor) {
94
+ return loadWorkflowExtensions(anchor)
95
+ .map(e => e.configContribution)
72
96
  .filter((c) => !!c);
73
97
  }
74
- /** 合并所有已安装扩展的能力片段 */
75
- function loadExtensionImplementations() {
76
- return Object.assign({}, ...loadExtensions().map(e => e.extension || {}));
98
+ /** 兼容旧名(已废弃:请用 loadWorkflowExtensions) */
99
+ function loadExtensionImplementations(anchor) {
100
+ return loadWorkflowExtensions(anchor);
77
101
  }
@@ -0,0 +1,3 @@
1
+ import { Pipeline } from './hooks';
2
+ /** 构建带全部基线实现的管道;随后调用各扩展 setup(hooks) 注入/覆盖 */
3
+ export declare function createDefaultPipeline(rt: any): Pipeline;
@@ -0,0 +1,71 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createDefaultPipeline = createDefaultPipeline;
37
+ const hooks_1 = require("./hooks");
38
+ const fetcher = __importStar(require("../engine/fetcher"));
39
+ const compose = __importStar(require("../engine/compose"));
40
+ const forward = __importStar(require("../engine/forward"));
41
+ async function sendDispatch(rt, input) {
42
+ const { items, session, sender, quoteId } = input;
43
+ const config = rt.config || {};
44
+ const forwardAllowed = config.enableForward && (session.platform === 'onebot' || session.platform === 'satori');
45
+ if (forwardAllowed) {
46
+ await forward.sendForward(rt, sender, session, items);
47
+ return;
48
+ }
49
+ if (config.sendStrategy === 'split') {
50
+ for (const item of items)
51
+ await compose.sendSplit(rt, sender, session, item, { quoteId });
52
+ return;
53
+ }
54
+ for (const item of items) {
55
+ await compose.sendSingle(rt, sender, session, item, { quoteId });
56
+ }
57
+ }
58
+ /** 构建带全部基线实现的管道;随后调用各扩展 setup(hooks) 注入/覆盖 */
59
+ function createDefaultPipeline(rt) {
60
+ const p = new hooks_1.Pipeline(rt);
61
+ p.replace('parse', (input, r) => fetcher.fetchApi(r, input.url, input.type, input.fieldMapping, input.platformConf));
62
+ p.replace('translate', async () => null);
63
+ p.replace('media.image', async (input) => ({ kind: 'raw', url: input.url }));
64
+ p.replace('media.video', async (input) => ({ kind: 'raw', url: input.videoUrl }));
65
+ p.replace('media.merged', async (input) => ({ kind: 'raw', buffer: input.buffer, url: input.refUrl }));
66
+ p.replace('merge', async () => null);
67
+ p.replace('transcode', async () => null);
68
+ p.replace('compose', (input, r) => compose.buildUnits(r, input.item));
69
+ p.replace('send', (input, r) => sendDispatch(r, input));
70
+ return p;
71
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 工作流阶段钩子管道(core 内建完整工作流,扩展经钩子注入/修改)。
3
+ *
4
+ * 每阶段结构:{ before[], impl, after[] }
5
+ * - before:前置,可改输入(返回新输入;返回 undefined 表示不变)
6
+ * - after:后置,可改输出(返回新输出;返回 undefined 表示不变)
7
+ * - replace:整体替换该阶段实现(后注册者胜)
8
+ *
9
+ * 基线实现见 workflow/default.ts(无扩展时全链路可跑:
10
+ * 不合并逐张发 / GIF 退化发视频 / 不翻译 / 媒体直通)。
11
+ */
12
+ import type { ParsedData } from '../types';
13
+ import type { ImageOutcome, VideoOutcome, TranslateResult, GifOptions, MediaKind, VideoMeta } from '../extensions';
14
+ import type { ProcessedItem, MessageUnit } from '../engine/compose';
15
+ import type { OutboundSender, SessionLike } from '../sender';
16
+ export type StageName = 'parse' | 'translate' | 'media.image' | 'media.video' | 'media.merged' | 'merge' | 'transcode' | 'compose' | 'send';
17
+ export interface ParseInput {
18
+ url: string;
19
+ type: string;
20
+ fieldMapping?: Record<string, string>;
21
+ platformConf?: any;
22
+ }
23
+ export interface TranslateInput {
24
+ text: string;
25
+ target: string;
26
+ sourceLang?: string;
27
+ }
28
+ export interface MediaImageInput {
29
+ platform: string;
30
+ url: string;
31
+ kind: MediaKind;
32
+ }
33
+ export interface MediaVideoInput {
34
+ platform: string;
35
+ videoUrl: string;
36
+ coverUrl: string;
37
+ meta: VideoMeta;
38
+ }
39
+ export interface MediaMergedInput {
40
+ platform: string;
41
+ buffer: Buffer;
42
+ refUrl: string;
43
+ }
44
+ export interface MergeInput {
45
+ urls: string[];
46
+ }
47
+ export type MergeResult = {
48
+ buffer: Buffer;
49
+ layout?: any;
50
+ } | null;
51
+ export interface TranscodeInput {
52
+ url: string;
53
+ durationSec: number;
54
+ opts: GifOptions;
55
+ }
56
+ export interface ComposeInput {
57
+ item: ProcessedItem;
58
+ }
59
+ export interface SendInput {
60
+ items: ProcessedItem[];
61
+ session: SessionLike;
62
+ sender: OutboundSender;
63
+ quoteId?: string;
64
+ }
65
+ export interface StageIO {
66
+ parse: {
67
+ input: ParseInput;
68
+ output: ParsedData;
69
+ };
70
+ translate: {
71
+ input: TranslateInput;
72
+ output: TranslateResult | null;
73
+ };
74
+ 'media.image': {
75
+ input: MediaImageInput;
76
+ output: ImageOutcome;
77
+ };
78
+ 'media.video': {
79
+ input: MediaVideoInput;
80
+ output: VideoOutcome;
81
+ };
82
+ 'media.merged': {
83
+ input: MediaMergedInput;
84
+ output: ImageOutcome | null;
85
+ };
86
+ merge: {
87
+ input: MergeInput;
88
+ output: MergeResult;
89
+ };
90
+ transcode: {
91
+ input: TranscodeInput;
92
+ output: Buffer | null;
93
+ };
94
+ compose: {
95
+ input: ComposeInput;
96
+ output: MessageUnit[];
97
+ };
98
+ send: {
99
+ input: SendInput;
100
+ output: void;
101
+ };
102
+ }
103
+ export type StageImpl<S extends StageName = StageName> = (input: StageIO[S]['input'], rt: any) => StageIO[S]['output'] | Promise<StageIO[S]['output']>;
104
+ export type BeforeHandler<S extends StageName = StageName> = (input: StageIO[S]['input'], rt: any) => StageIO[S]['input'] | void | Promise<StageIO[S]['input'] | void>;
105
+ export type AfterHandler<S extends StageName = StageName> = (output: StageIO[S]['output'], input: StageIO[S]['input'], rt: any) => StageIO[S]['output'] | void | Promise<StageIO[S]['output'] | void>;
106
+ /** 扩展注入面:before/after 可插入,replace 可整体替换(后注册者胜) */
107
+ export interface WorkflowHooks {
108
+ before<S extends StageName>(stage: S, h: BeforeHandler<S>): void;
109
+ after<S extends StageName>(stage: S, h: AfterHandler<S>): void;
110
+ replace<S extends StageName>(stage: S, h: StageImpl<S>): void;
111
+ }
112
+ export declare class Pipeline implements WorkflowHooks {
113
+ rt: any;
114
+ private impls;
115
+ private befores;
116
+ private afters;
117
+ constructor(rt: any);
118
+ before<S extends StageName>(stage: S, h: BeforeHandler<S>): void;
119
+ after<S extends StageName>(stage: S, h: AfterHandler<S>): void;
120
+ replace<S extends StageName>(stage: S, h: StageImpl<S>): void;
121
+ run<S extends StageName>(stage: S, input: StageIO[S]['input']): Promise<StageIO[S]['output']>;
122
+ }
123
+ /** 取(或懒挂)runtime 的管道;裸 rt 对象也能工作(自动挂默认管道)。 */
124
+ export declare function ensurePipeline(rt: any): Pipeline;
125
+ export declare function runStage<S extends StageName>(rt: any, stage: S, input: StageIO[S]['input']): Promise<StageIO[S]['output']>;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Pipeline = void 0;
4
+ exports.ensurePipeline = ensurePipeline;
5
+ exports.runStage = runStage;
6
+ class Pipeline {
7
+ constructor(rt) {
8
+ this.rt = rt;
9
+ this.impls = new Map();
10
+ this.befores = new Map();
11
+ this.afters = new Map();
12
+ }
13
+ before(stage, h) {
14
+ const list = this.befores.get(stage) || [];
15
+ list.push(h);
16
+ this.befores.set(stage, list);
17
+ }
18
+ after(stage, h) {
19
+ const list = this.afters.get(stage) || [];
20
+ list.push(h);
21
+ this.afters.set(stage, list);
22
+ }
23
+ replace(stage, h) {
24
+ this.impls.set(stage, h);
25
+ }
26
+ async run(stage, input) {
27
+ let cur = input;
28
+ for (const h of this.befores.get(stage) || []) {
29
+ const r = await h(cur, this.rt);
30
+ if (r !== undefined)
31
+ cur = r;
32
+ }
33
+ const impl = this.impls.get(stage);
34
+ if (!impl)
35
+ throw new Error(`workflow: 阶段缺少实现: ${stage}`);
36
+ let out = await impl(cur, this.rt);
37
+ for (const h of this.afters.get(stage) || []) {
38
+ const r = await h(out, cur, this.rt);
39
+ if (r !== undefined)
40
+ out = r;
41
+ }
42
+ return out;
43
+ }
44
+ }
45
+ exports.Pipeline = Pipeline;
46
+ /** 取(或懒挂)runtime 的管道;裸 rt 对象也能工作(自动挂默认管道)。 */
47
+ function ensurePipeline(rt) {
48
+ if (rt && rt.pipeline instanceof Pipeline)
49
+ return rt.pipeline;
50
+ const { createDefaultPipeline } = require('./default');
51
+ const p = createDefaultPipeline(rt);
52
+ if (rt)
53
+ rt.pipeline = p;
54
+ return p;
55
+ }
56
+ function runStage(rt, stage, input) {
57
+ return ensurePipeline(rt).run(stage, input);
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sns-parse/core",
3
- "version": "0.5.0-alpha.1+upstream.1.6.7",
3
+ "version": "0.6.0-alpha.1+upstream.1.6.7",
4
4
  "description": "sns-parse 核心:契约、宿主抽象、配置 DSL 与解析引擎(fetcher/parser/翻译/X 原生解析/合并/GIF/TLS 指纹客户端)",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",