dsh-fish-soul 0.13.8 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/attachments.js +187 -0
- package/lib/browse.js +206 -6
- package/lib/channels/bilibili.js +51 -1
- package/lib/channels/onebot.js +67 -5
- package/lib/client.js +44 -11
- package/lib/config-platforms.js +8 -0
- package/lib/config-schema.js +8 -1
- package/lib/gateway-media.js +38 -2
- package/lib/gateway.js +107 -2
- package/lib/index.js +7 -3
- package/lib/llm.js +39 -2
- package/lib/panel.css +1 -1
- package/lib/routes.js +23 -0
- package/lib/social.js +5 -1
- package/lib/video-frames.js +97 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attachments.js —— QQ 附件(文件消息)处理:文本类直接读进正文,看不了的格式存到插件数据目录再报路径。
|
|
3
|
+
*
|
|
4
|
+
* 只碰白名单目录里的文件(工作区 / 插件数据目录):路径不在里面、文件已经没了、读失败,
|
|
5
|
+
* 一律保留原来的【文件:名字】占位,不往提示词里塞来路不明的东西。
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
9
|
+
import { isInside } from './gateway-util.js';
|
|
10
|
+
|
|
11
|
+
/** 能直接读进提示词的扩展名。 */
|
|
12
|
+
const TEXT_EXT = new Set(['.txt', '.md', '.log', '.csv', '.json']);
|
|
13
|
+
/** 超过这个大小就不读(提示词放不下)。 */
|
|
14
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
15
|
+
/** 直读最多读前多少字节。 */
|
|
16
|
+
const MAX_READ_BYTES = 8 * 1024;
|
|
17
|
+
/** 二进制落盘单文件上限(超过就保留占位)。 */
|
|
18
|
+
const MAX_BINARY_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
/** 正文里附件的占位(onebot.js 生成的);用户自己打的【】会被换成〔〕,撞不上。 */
|
|
20
|
+
const PLACEHOLDER_RE = /【文件[^】]*】/g;
|
|
21
|
+
|
|
22
|
+
/** 附件开关:读文本 / 存二进制。 */
|
|
23
|
+
export function attachmentConfig(cfg) {
|
|
24
|
+
const a = cfg?.attachments ?? {};
|
|
25
|
+
return { readText: a.readText !== false, saveBinary: a.saveBinary === true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 文件名里不能有的字符(Windows 也不能有),顺手去掉开头的小点。 */
|
|
29
|
+
function safeName(name) {
|
|
30
|
+
return String(name ?? '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/^\.+/, '').trim().slice(0, 60);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 读前 8KB;切在多字节字符中间会多出一个替换字符,丢掉。 */
|
|
34
|
+
function readHead(abs) {
|
|
35
|
+
const buf = readFileSync(abs);
|
|
36
|
+
const truncated = buf.length > MAX_READ_BYTES;
|
|
37
|
+
const slice = buf.subarray(0, MAX_READ_BYTES);
|
|
38
|
+
// 先检测是否合法 UTF-8;非法就抛异常(调用方 catch 保留占位)
|
|
39
|
+
new TextDecoder('utf-8', { fatal: true }).decode(slice);
|
|
40
|
+
const text = slice.toString('utf8');
|
|
41
|
+
// 切在多字节字符中间会多出一个替换字符(U+FFFD),丢掉
|
|
42
|
+
const clean = text.length > 0 && text.codePointAt(text.length - 1) === 0xfffd ? text.slice(0, -1) : text;
|
|
43
|
+
return { text: clean, truncated };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* fileId 路线(SnowLuma 这类不给 path 的):get_private_file_url 换直链 → 下载 → 按扩展名读进正文或落盘。
|
|
48
|
+
* 拿不到链接 / 下载失败 / 超上限:返回 null 保留占位。
|
|
49
|
+
*/
|
|
50
|
+
async function handleDownload(f, ctx, meta) {
|
|
51
|
+
const { name, ext, size, label, warn } = meta;
|
|
52
|
+
const fileId = String(f?.fileId ?? '').trim();
|
|
53
|
+
const fileHash = String(f?.fileHash ?? '').trim();
|
|
54
|
+
if (typeof ctx.fetchFile !== 'function') { warn('没有文件下载通道'); return null; }
|
|
55
|
+
if (size > MAX_BINARY_BYTES) { warn('太大了,没拉'); return null; }
|
|
56
|
+
let bytes = null;
|
|
57
|
+
try {
|
|
58
|
+
bytes = await ctx.fetchFile({ fileId, fileHash, url: String(f?.url ?? '') });
|
|
59
|
+
} catch {
|
|
60
|
+
warn('下载失败');
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (bytes === null || bytes.length === 0) { warn('没拉到内容'); return null; }
|
|
64
|
+
if (bytes.length > MAX_BINARY_BYTES) { warn('太大了,没拉'); return null; }
|
|
65
|
+
|
|
66
|
+
if (TEXT_EXT.has(ext)) {
|
|
67
|
+
if (!ctx.readText) return null;
|
|
68
|
+
if (bytes.length > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
69
|
+
const slice = bytes.subarray(0, MAX_READ_BYTES);
|
|
70
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(slice); } catch { warn('不是有效 UTF-8'); return null; }
|
|
71
|
+
const text = slice.toString('utf8');
|
|
72
|
+
const clean = text.length > 0 && text.codePointAt(text.length - 1) === 0xfffd ? text.slice(0, -1) : text;
|
|
73
|
+
ctx.read.push(label);
|
|
74
|
+
const body = bytes.length > MAX_READ_BYTES ? `${clean}\n…(内容太长,只读了前 8KB)` : clean;
|
|
75
|
+
return `【文件 ${label} 内容】\n${body}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!ctx.saveBinary) return null;
|
|
79
|
+
const dir = String(ctx.dataDir ?? '');
|
|
80
|
+
if (dir === '') { warn('没有存放目录'); return null; }
|
|
81
|
+
const base = safeName(name);
|
|
82
|
+
const dest = join(dir, `${safeName(ctx.messageId) || 'file'}-${base !== '' ? base : '附件'}`);
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeFileSync(dest, bytes);
|
|
86
|
+
} catch {
|
|
87
|
+
warn('存不下来');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
ctx.saved.push(dest);
|
|
91
|
+
return `【文件 ${label}:我看不了这种格式,已存到 ${dest}】`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 一个附件怎么处理:返回替换占位的新文本,处理不了返回 null(占位原样留着)。
|
|
96
|
+
* SnowLuma 这类不给本地 path 的:先用 fetchFile(get_private_file_url 换链接再下载)拿到 bytes 再走同一套逻辑。
|
|
97
|
+
* @param {{name?: string, path?: string, url?: string, size?: number, fileId?: string, fileHash?: string}} f
|
|
98
|
+
* @param {object} ctx
|
|
99
|
+
*/
|
|
100
|
+
async function handleOne(f, ctx) {
|
|
101
|
+
const name = String(f?.name ?? '').trim();
|
|
102
|
+
const rawPath = String(f?.path ?? '').trim();
|
|
103
|
+
const abs = rawPath === '' ? '' : resolve(rawPath);
|
|
104
|
+
const ext = extname(name !== '' ? name : rawPath).toLowerCase();
|
|
105
|
+
const size = Number(f?.size) || 0;
|
|
106
|
+
const label = name !== '' ? name : rawPath !== '' ? basename(rawPath) : '未命名';
|
|
107
|
+
const warn = (why) => ctx.log?.('warn', `[fish-soul] 附件没读进去(${why}):${label}`);
|
|
108
|
+
|
|
109
|
+
// SnowLuma 路线:没有本地 path,但有 fileId——先用 OneBot API 换链接下载
|
|
110
|
+
const fileId = String(f?.fileId ?? '').trim();
|
|
111
|
+
if (abs === '' && fileId !== '') return await handleDownload(f, ctx, { name, ext, size, label, warn });
|
|
112
|
+
|
|
113
|
+
if (abs === '') { warn('没有本地路径'); return null; }
|
|
114
|
+
if (!ctx.roots.some((root) => isInside(root, abs))) { warn('路径不在允许的目录里'); return null; }
|
|
115
|
+
if (!existsSync(abs)) { warn('文件不在了'); return null; }
|
|
116
|
+
|
|
117
|
+
// P0 修复:白名单和源文件都做 realpath,防符号链接 / Windows junction 绕过
|
|
118
|
+
let realRoots;
|
|
119
|
+
try { realRoots = ctx.roots.filter((r) => { try { realpathSync.native(r); return true; } catch { return false; } }).map((r) => realpathSync.native(r)); } catch { realRoots = []; }
|
|
120
|
+
let realAbs;
|
|
121
|
+
try { realAbs = realpathSync.native(abs); } catch { warn('解析真实路径失败'); return null; }
|
|
122
|
+
if (!realRoots.some((root) => isInside(root, realAbs))) { warn('真实路径越界(符号链接/junction)'); return null; }
|
|
123
|
+
|
|
124
|
+
if (TEXT_EXT.has(ext)) {
|
|
125
|
+
if (!ctx.readText) return null; // 开关关着:跟以前一样,只留占位
|
|
126
|
+
if (size > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
127
|
+
let st;
|
|
128
|
+
try { st = statSync(realAbs); } catch { warn('拿不到文件信息'); return null; }
|
|
129
|
+
if (!st.isFile()) { warn('不是文件'); return null; }
|
|
130
|
+
if (st.size > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
131
|
+
let head;
|
|
132
|
+
try { head = readHead(realAbs); } catch { warn('读取失败'); return null; }
|
|
133
|
+
ctx.read.push(label);
|
|
134
|
+
const body = head.truncated ? `${head.text}\n…(内容太长,只读了前 8KB)` : head.text;
|
|
135
|
+
return `【文件 ${label} 内容】\n${body}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!ctx.saveBinary) return null; // 不存二进制:跟以前一样,只留占位
|
|
139
|
+
// P1 修复:二进制也设上限,防大文件占满磁盘
|
|
140
|
+
if (size > MAX_BINARY_BYTES) { warn('二进制太大,没存'); return null; }
|
|
141
|
+
let bst;
|
|
142
|
+
try { bst = statSync(realAbs); } catch { warn('拿不到文件信息'); return null; }
|
|
143
|
+
if (!bst.isFile()) { warn('不是普通文件'); return null; }
|
|
144
|
+
if (bst.size > MAX_BINARY_BYTES) { warn('二进制太大,没存'); return null; }
|
|
145
|
+
const dir = String(ctx.dataDir ?? '');
|
|
146
|
+
if (dir === '') { warn('没有存放目录'); return null; }
|
|
147
|
+
const base = safeName(name) !== '' ? safeName(name) : safeName(basename(realAbs));
|
|
148
|
+
const dest = join(dir, `${safeName(ctx.messageId) || 'file'}-${base !== '' ? base : '附件'}`);
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
copyFileSync(realAbs, dest);
|
|
152
|
+
} catch {
|
|
153
|
+
warn('存不下来');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
ctx.saved.push(dest);
|
|
157
|
+
return `【文件 ${label}:我看不了这种格式,已存到 ${dest}】`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 把附件处理进消息正文:占位换成内容(或存下来的路径),处理不了的占位原样留着。
|
|
162
|
+
* @param {string} text 原正文(里面有【文件:名字】占位)
|
|
163
|
+
* @param {Array<{name?: string, path?: string, url?: string, size?: number}>} files 附件(顺序与占位一致)
|
|
164
|
+
* @param {{readText?: boolean, saveBinary?: boolean, roots?: string[], dataDir?: string, messageId?: string, log?: (level: string, msg: string) => void}} opts
|
|
165
|
+
* @returns {{text: string, read: string[], saved: string[]}} read = 读进正文的附件名,saved = 落盘的绝对路径
|
|
166
|
+
*/
|
|
167
|
+
export async function applyAttachments(text, files, opts = {}) {
|
|
168
|
+
const src = String(text ?? '');
|
|
169
|
+
const list = Array.isArray(files) ? files : [];
|
|
170
|
+
if (list.length === 0) return { text: src, read: [], saved: [] };
|
|
171
|
+
const marks = [...src.matchAll(PLACEHOLDER_RE)];
|
|
172
|
+
if (marks.length === 0) return { text: src, read: [], saved: [] };
|
|
173
|
+
const ctx = { readText: true, saveBinary: false, roots: [], dataDir: '', messageId: '', log: () => {}, ...opts };
|
|
174
|
+
const read = [];
|
|
175
|
+
const saved = [];
|
|
176
|
+
ctx.read = read;
|
|
177
|
+
ctx.saved = saved;
|
|
178
|
+
const repl = await Promise.all(marks.map((mark, i) => (list[i] === undefined ? null : handleOne(list[i], ctx))));
|
|
179
|
+
// 从后往前替换,前面占位的位置不受影响
|
|
180
|
+
let out = src;
|
|
181
|
+
for (let i = repl.length - 1; i >= 0; i--) {
|
|
182
|
+
if (repl[i] === null) continue;
|
|
183
|
+
const at = marks[i].index;
|
|
184
|
+
out = out.slice(0, at) + repl[i] + out.slice(at + marks[i][0].length);
|
|
185
|
+
}
|
|
186
|
+
return { text: out, read, saved };
|
|
187
|
+
}
|
package/lib/browse.js
CHANGED
|
@@ -56,7 +56,7 @@ export class BiliBrowser {
|
|
|
56
56
|
* @param {(level: string, msg: string) => void} [deps.log]
|
|
57
57
|
* @param {[number, number]} [deps.watchDelayMs] 两条视频之间"看"多久(真实毫秒)
|
|
58
58
|
*/
|
|
59
|
-
constructor({ db, clock, client, getConfig, callLlm, persona, worker, log, watchDelayMs = [8000, 20000], share, note }) {
|
|
59
|
+
constructor({ db, clock, client, getConfig, callLlm, persona, worker, log, watchDelayMs = [8000, 20000], share, note, frames }) {
|
|
60
60
|
this.share = share ?? null; // 刷到特别想分享的:交给自走层排一条主动消息(带链接)
|
|
61
61
|
this.note = note ?? null; // 记一条动态
|
|
62
62
|
this.db = db;
|
|
@@ -70,6 +70,7 @@ export class BiliBrowser {
|
|
|
70
70
|
this.watchDelayMs = watchDelayMs;
|
|
71
71
|
this.running = false;
|
|
72
72
|
this.described = new Map();
|
|
73
|
+
this.framesOf = frames ?? null; // 抽九宫格看画面(可缺:没有就只读文字)
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
cfg() {
|
|
@@ -84,7 +85,15 @@ export class BiliBrowser {
|
|
|
84
85
|
share: br.share !== false,
|
|
85
86
|
favorite: br.favorite === true,
|
|
86
87
|
favoritePerDay: num(br.favoritePerDay, 2, 0, 10),
|
|
88
|
+
commentReply: br.commentReply === true,
|
|
89
|
+
commentReplyPerDay: num(br.commentReplyPerDay, 1, 0, 5),
|
|
90
|
+
hotComments: num(br.hotComments, 6, 0, 10),
|
|
87
91
|
danmaku: br.danmaku !== false,
|
|
92
|
+
comment: br.comment === true,
|
|
93
|
+
commentPerDay: num(br.commentPerDay, 1, 0, 5),
|
|
94
|
+
frames: br.frames === true,
|
|
95
|
+
framesPerDay: num(br.framesPerDay, 1, 0, 5),
|
|
96
|
+
framesPerDayChat: num(br.framesPerDayChat, 10, 0, 30),
|
|
88
97
|
search: br.search === true,
|
|
89
98
|
searchKeywords: (Array.isArray(br.searchKeywords) ? br.searchKeywords : [])
|
|
90
99
|
.map((w) => String(w ?? '').trim()).filter((w) => w !== '').slice(0, 20),
|
|
@@ -149,7 +158,7 @@ export class BiliBrowser {
|
|
|
149
158
|
try { subtitle = await this.client.subtitleText(info.bvid, info.cid, subtitleChars); } catch { /* 没字幕 */ }
|
|
150
159
|
}
|
|
151
160
|
let comments = [];
|
|
152
|
-
try { comments = await this.client.hotComments(info.aid,
|
|
161
|
+
try { comments = await this.client.hotComments(info.aid, this.cfg().hotComments); } catch { /* 没评论 */ }
|
|
153
162
|
let danmakuLines = [];
|
|
154
163
|
if (danmaku && typeof this.client.danmaku === 'function') {
|
|
155
164
|
try { danmakuLines = await this.client.danmaku(info.cid, 12); } catch { /* 没弹幕 / 解不开 */ }
|
|
@@ -157,10 +166,40 @@ export class BiliBrowser {
|
|
|
157
166
|
return { ...info, subtitle, comments, danmaku: danmakuLines };
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
framesDayKey() {
|
|
170
|
+
return `bili.frames.${this.clock.getRoleDateParts().dateStr}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
framedToday() {
|
|
174
|
+
return Number(getKv(this.db, this.framesDayKey(), 0)) || 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 你发链接给她时抽的帧,另记一本账,不占她自己刷视频的次数。 */
|
|
178
|
+
framesChatDayKey() {
|
|
179
|
+
return `bili.frames.chat.${this.clock.getRoleDateParts().dateStr}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
framedTodayChat() {
|
|
183
|
+
return Number(getKv(this.db, this.framesChatDayKey(), 0)) || 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
160
186
|
favDayKey() {
|
|
161
187
|
return `bili.fav.${this.clock.getRoleDateParts().dateStr}`;
|
|
162
188
|
}
|
|
163
189
|
|
|
190
|
+
replyDayKey() {
|
|
191
|
+
return `bili.reply.${this.clock.getRoleDateParts().dateStr}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
repliedToday() {
|
|
195
|
+
return Number(getKv(this.db, this.replyDayKey(), 0)) || 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 最近一次回过的评论(设置页显示用),没回过返回 null。 */
|
|
199
|
+
recentReply() {
|
|
200
|
+
return getKv(this.db, 'bili.reply.last', null) ?? null;
|
|
201
|
+
}
|
|
202
|
+
|
|
164
203
|
favoritedToday() {
|
|
165
204
|
return Number(getKv(this.db, this.favDayKey(), 0)) || 0;
|
|
166
205
|
}
|
|
@@ -173,6 +212,14 @@ export class BiliBrowser {
|
|
|
173
212
|
return Number(getKv(this.db, this.searchDayKey(), 0)) || 0;
|
|
174
213
|
}
|
|
175
214
|
|
|
215
|
+
commentDayKey() {
|
|
216
|
+
return `bili.comment.${this.clock.getRoleDateParts().dateStr}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
commentedToday() {
|
|
220
|
+
return Number(getKv(this.db, this.commentDayKey(), 0)) || 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
176
223
|
/** 她最近看过的几条标题(给她自己提搜索词当原料)。 */
|
|
177
224
|
recentTitles(n = 5) {
|
|
178
225
|
try {
|
|
@@ -234,7 +281,7 @@ export class BiliBrowser {
|
|
|
234
281
|
return drafted;
|
|
235
282
|
}
|
|
236
283
|
|
|
237
|
-
promptFor(v, like, share = false, favorite = false) {
|
|
284
|
+
promptFor(v, like, share = false, favorite = false, hasFrames = false) {
|
|
238
285
|
const p = this.persona?.() ?? { name: '鱼', brief: '' };
|
|
239
286
|
return [
|
|
240
287
|
`你是「${p.name}」。${p.brief}`.trim(),
|
|
@@ -244,6 +291,7 @@ export class BiliBrowser {
|
|
|
244
291
|
v.desc ? `简介:${oneLine(v.desc, 200)}` : '',
|
|
245
292
|
v.subtitle ? `字幕节选:${v.subtitle}` : '(这个视频没有字幕,只能看简介和评论)',
|
|
246
293
|
v.comments.length > 0 ? `热评:\n${v.comments.map((c) => `- ${c}`).join('\n')}` : '',
|
|
294
|
+
hasFrames ? '画面:附了一张九宫格截图(从开头每 6 秒一格,共 9 格)。如果画面能看清,就在感受里写一句画面里的具体细节——谁在做什么、什么颜色、场景长什么样;实在看不清就写“没看到画面”。' : '',
|
|
247
295
|
(v.danmaku ?? []).length > 0 ? `弹幕(按时间抽了几条):${v.danmaku.map((d) => oneLine(d, 30)).join(' / ')}` : '',
|
|
248
296
|
'',
|
|
249
297
|
'用第一人称、口语化写一两句你看完的感受(大概讲了什么 + 你怎么看),不超过 80 字,不要照抄标题。',
|
|
@@ -264,9 +312,147 @@ export class BiliBrowser {
|
|
|
264
312
|
return id;
|
|
265
313
|
}
|
|
266
314
|
|
|
315
|
+
/**
|
|
316
|
+
* 要不要抽帧看画面:开关开着、有 cid、不是短片、今天还有额度。
|
|
317
|
+
* 失败只记一条 warn,返回 null —— 绝不因为抽帧失败就看不到视频。
|
|
318
|
+
*/
|
|
319
|
+
async frameIfWanted(v, c) {
|
|
320
|
+
if (c.frames !== true || typeof this.framesOf !== 'function') return null;
|
|
321
|
+
if (!v.cid || (Number(v.duration ?? 0) < 120)) return null;
|
|
322
|
+
if (this.framedToday() >= Number(c.framesPerDay ?? 1)) return null;
|
|
323
|
+
try {
|
|
324
|
+
const img = await this.framesOf(v);
|
|
325
|
+
if (img?.data) {
|
|
326
|
+
setKv(this.db, this.framesDayKey(), this.framedToday() + 1);
|
|
327
|
+
return img;
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
this.log('warn', `[fish-soul] 抽帧没成(照旧只看文字):${String(error instanceof Error ? error.message : error)}`);
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 你发链接过来时抽一帧,额度跟"自己刷"分开。 */
|
|
336
|
+
async frameForLink(v, c) {
|
|
337
|
+
if (c.frames !== true || typeof this.framesOf !== 'function') return null;
|
|
338
|
+
if (!v.cid || (Number(v.duration ?? 0) < 60)) return null;
|
|
339
|
+
if (this.framedTodayChat() >= Number(c.framesPerDayChat ?? 10)) return null;
|
|
340
|
+
try {
|
|
341
|
+
const img = await this.framesOf(v);
|
|
342
|
+
if (img?.data) {
|
|
343
|
+
setKv(this.db, this.framesChatDayKey(), this.framedTodayChat() + 1);
|
|
344
|
+
return img;
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.log('warn', `[fish-soul] 分享抽帧没成(照旧只看文字):${String(error instanceof Error ? error.message : error)}`);
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 把一张九宫格读成一句话,写进聊天时她能看到的那段文字里。 */
|
|
353
|
+
async visualNote(v, frame) {
|
|
354
|
+
try {
|
|
355
|
+
const text = await this.callLlm(
|
|
356
|
+
`这是 B站 视频《${oneLine(v.title, 40)}》开头的九宫格截图(每格约 6 秒)。` +
|
|
357
|
+
'用一句不超过 30 字的话,写画面里看到的具体东西:谁在做什么、什么颜色、什么场景。' +
|
|
358
|
+
'看不清就只写「没看清」。不要分点、不要引号、不要写"这张图"。',
|
|
359
|
+
{ feature: 'browse', maxTokens: 80, images: [frame] }
|
|
360
|
+
);
|
|
361
|
+
const t = oneLine(String(text ?? '').replace(/^["“]|["”]$/g, ''), 40);
|
|
362
|
+
return t && t !== '没看清' ? t : '';
|
|
363
|
+
} catch (error) {
|
|
364
|
+
this.log('warn', `[fish-soul] 读画面没成:${String(error instanceof Error ? error.message : error)}`);
|
|
365
|
+
return '';
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 评论区留一句的提示词(短、口语、像真人随手写的)。 */
|
|
370
|
+
commentPrompt(v, impression) {
|
|
371
|
+
const p = this.persona?.() ?? { name: '鱼', brief: '' };
|
|
372
|
+
return [
|
|
373
|
+
`你是「${p.name}」。${p.brief}`.trim(),
|
|
374
|
+
`你刚看完 B站 视频《${oneLine(v.title, 60)}》${v.owner ? `(UP主 ${oneLine(v.owner, 20)})` : ''}。`,
|
|
375
|
+
`你自己记下的感受:${oneLine(impression, 100)}`,
|
|
376
|
+
'现在想在评论区留一句(不是回谁,就是自己看完想说)。',
|
|
377
|
+
'要求:第一人称、口语、一到两句,不超过 25 个字;像真人随手写的,不要「打卡」「沙发」「前排」这类;不@人、不贴链接、不复述标题、不评价 UP主 本人。',
|
|
378
|
+
'只想说「好看」这种空话,就只输出 [不评]。否则只输出那句话本身。'
|
|
379
|
+
].filter(Boolean).join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 在视频底下留一句。成功返回评论文本,没留返回 ''。 */
|
|
383
|
+
async commentOn(v, impression) {
|
|
384
|
+
let text = '';
|
|
385
|
+
try {
|
|
386
|
+
const out = String((await this.callLlm(this.commentPrompt(v, impression))) ?? '').trim();
|
|
387
|
+
if (/\[不评\]/.test(out)) return '';
|
|
388
|
+
text = oneLine(out.replace(/^[「“"']|[」”"']$/g, ''), 25);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
this.log('warn', `[fish-soul] 想评论没想出来:${String(error instanceof Error ? error.message : error)}`);
|
|
391
|
+
return '';
|
|
392
|
+
}
|
|
393
|
+
if (text === '') return '';
|
|
394
|
+
try {
|
|
395
|
+
await Promise.race([
|
|
396
|
+
this.client.replyComment({ type: 1, oid: v.aid, root: 0, parent: 0, message: text }),
|
|
397
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('评论发帖超时')), 15_000))
|
|
398
|
+
]);
|
|
399
|
+
setKv(this.db, this.commentDayKey(), this.commentedToday() + 1);
|
|
400
|
+
this.log('info', `[fish-soul] 在《${oneLine(v.title, 30)}》评论区留了一句:${text}`);
|
|
401
|
+
return text;
|
|
402
|
+
} catch (error) {
|
|
403
|
+
this.log('warn', `[fish-soul] 评论没发出去:${String(error instanceof Error ? error.message : error)}`);
|
|
404
|
+
return '';
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 楼中楼:从视频现有评论里挑一条值得搭话的,回一句(只回一条,不追着聊)。
|
|
410
|
+
* 模型输出「序号|回复内容」;[不回] / 序号不合法 / 内容为空都算没回。
|
|
411
|
+
*/
|
|
412
|
+
async commentReplyOn(v) {
|
|
413
|
+
let list = [];
|
|
414
|
+
try {
|
|
415
|
+
list = await this.client.commentList(v.aid, 10);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
this.log('warn', `[fish-soul] 读评论没成:${String(error instanceof Error ? error.message : error)}`);
|
|
418
|
+
return '';
|
|
419
|
+
}
|
|
420
|
+
const me = String(this.client.uid ?? '');
|
|
421
|
+
list = (list ?? []).filter((x) => x.content && x.rpid && String(x.mid) !== me);
|
|
422
|
+
if (list.length === 0) return '';
|
|
423
|
+
const p = this.persona?.() ?? { name: '鱼', brief: '' };
|
|
424
|
+
const lines = [
|
|
425
|
+
`你是「${p.name}」。${p.brief}`.trim(),
|
|
426
|
+
`你刚看完 B站 视频《${oneLine(v.title, 50)}》。评论区长这样:`,
|
|
427
|
+
...list.map((x, i) => `${i + 1}. ${x.name || '有人'}:${x.content}`),
|
|
428
|
+
'挑一条你想接话的,回一句。要求:第一人称、口语、一到两句,不超过 30 个字;',
|
|
429
|
+
'不要复述他说的话,不要@人,不要引战、不要跟人抬杠,不评价 UP主 本人,广告和骂人的别接。',
|
|
430
|
+
'没有想接的就只输出 [不回]。要回就只输出「序号|回复内容」,两行都不要解释。'
|
|
431
|
+
];
|
|
432
|
+
const out = String((await this.callLlm(lines.join('\n'))) ?? '').trim();
|
|
433
|
+
if (out === '' || /\[不回\]/.test(out)) return '';
|
|
434
|
+
const m = out.match(/^\s*(\d{1,2})\s*[||]\s*([\s\S]+)$/);
|
|
435
|
+
if (!m) return '';
|
|
436
|
+
const pick = list[Number(m[1]) - 1];
|
|
437
|
+
const text = oneLine(m[2].replace(/^[「“"']|[」”"']$/g, ''), 60);
|
|
438
|
+
if (!pick || text === '') return '';
|
|
439
|
+
try {
|
|
440
|
+
await this.client.replyComment({ type: 1, oid: v.aid, root: pick.rpid, parent: 0, message: text });
|
|
441
|
+
setKv(this.db, this.replyDayKey(), this.repliedToday() + 1);
|
|
442
|
+
setKv(this.db, 'bili.reply.last', { bvid: v.bvid, title: oneLine(v.title, 40), name: pick.name, text, root: pick.rpid, at: Date.now() });
|
|
443
|
+
this.log('info', `[fish-soul] 回了 @${pick.name} 的评论:${text}`);
|
|
444
|
+
return text;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
this.log('warn', `[fish-soul] 回评论没发出去:${String(error instanceof Error ? error.message : error)}`);
|
|
447
|
+
return '';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
267
451
|
async watch(item, like, share = false, favorite = false) {
|
|
268
452
|
const v = await this.gather(item.bvid);
|
|
269
|
-
const
|
|
453
|
+
const c = this.cfg();
|
|
454
|
+
const frame = await this.frameIfWanted(v, c);
|
|
455
|
+
const out = await this.callLlm(this.promptFor(v, like, share, favorite, frame !== null), frame ? { images: [frame] } : undefined);
|
|
270
456
|
const raw = String(out ?? '').trim();
|
|
271
457
|
const skipped = /\[划走\]/.test(raw);
|
|
272
458
|
const liked = like && /\[点赞\]/.test(raw);
|
|
@@ -296,13 +482,24 @@ export class BiliBrowser {
|
|
|
296
482
|
this.log('warn', `[fish-soul] 收藏失败:${String(error instanceof Error ? error.message : error)}`);
|
|
297
483
|
}
|
|
298
484
|
}
|
|
485
|
+
// 评论区留一句:只在真看完、有感受、开关开着、今天还没到额度时
|
|
486
|
+
let commented = '';
|
|
487
|
+
if (!skipped && impression !== '' && c.comment && v.aid && this.commentedToday() < c.commentPerDay) {
|
|
488
|
+
commented = await this.commentOn(v, impression);
|
|
489
|
+
}
|
|
490
|
+
// 楼中楼:回一条别人的评论(默认关,单独额度)
|
|
491
|
+
let replied = '';
|
|
492
|
+
const replyCfg = this.cfg();
|
|
493
|
+
if (!skipped && replyCfg.commentReply && v.aid && this.repliedToday() < replyCfg.commentReplyPerDay) {
|
|
494
|
+
replied = await this.commentReplyOn(v);
|
|
495
|
+
}
|
|
299
496
|
this.db.prepare(`
|
|
300
497
|
INSERT INTO bili_seen (bvid, aid, title, owner_name, source, impression, skipped, liked, favorited, memory_id, seen_at, seen_real_at)
|
|
301
498
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
302
499
|
ON CONFLICT(bvid) DO NOTHING
|
|
303
500
|
`).run(v.bvid, v.aid, oneLine(v.title, 120), oneLine(v.owner, 40), item.source ?? 'recommend', impression,
|
|
304
501
|
skipped ? 1 : 0, liked ? 1 : 0, favorited ? 1 : 0, memoryId, this.clock.now(), Date.now());
|
|
305
|
-
return { bvid: v.bvid, title: v.title, owner: v.owner, impression, skipped, liked, favorited, share: wantShare && impression !== '', url: `https://www.bilibili.com/video/${v.bvid}` };
|
|
502
|
+
return { bvid: v.bvid, title: v.title, owner: v.owner, impression, skipped, liked, favorited, commented, replied, share: wantShare && impression !== '', url: `https://www.bilibili.com/video/${v.bvid}` };
|
|
306
503
|
}
|
|
307
504
|
|
|
308
505
|
/** 刷一轮。返回看过的视频。 */
|
|
@@ -363,10 +560,13 @@ export class BiliBrowser {
|
|
|
363
560
|
if (hit !== undefined && Date.now() - hit.at < DESCRIBE_TTL_MS) { notes.push(hit.text); continue; }
|
|
364
561
|
try {
|
|
365
562
|
const v = await this.gather(bvid, { subtitleChars: c.subtitleChars, danmaku: false });
|
|
563
|
+
const frame = await this.frameForLink(v, c);
|
|
564
|
+
const visual = frame ? await this.visualNote(v, frame) : '';
|
|
366
565
|
const text = `【视频 ${bvid}:《${oneLine(v.title, 60)}》UP主 ${oneLine(v.owner, 20) || '未知'}` +
|
|
367
566
|
(v.desc ? `;简介:${oneLine(v.desc, c.descChars)}` : '') +
|
|
368
567
|
(v.subtitle ? `;字幕节选:${oneLine(v.subtitle, c.subtitleChars)}` : '') +
|
|
369
|
-
(v.comments[0] ? `;热评:${oneLine(v.comments[0], c.hotCommentChars)}` : '') +
|
|
568
|
+
(v.comments[0] ? `;热评:${oneLine(v.comments[0], c.hotCommentChars)}` : '') +
|
|
569
|
+
(visual ? `;画面:${visual}` : '') + '】';
|
|
370
570
|
this.described.set(bvid, { text, at: Date.now() });
|
|
371
571
|
if (this.described.size > 200) this.described.delete(this.described.keys().next().value);
|
|
372
572
|
notes.push(text);
|
package/lib/channels/bilibili.js
CHANGED
|
@@ -546,6 +546,24 @@ export class BiliClient {
|
|
|
546
546
|
return body.map((b) => String(b.content ?? '')).join(',').slice(0, maxChars);
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
/**
|
|
550
|
+
* 视频评论列表(给"回一条别人的评论"用):带 rpid / 作者 / 内容,按热度取前 n 条。
|
|
551
|
+
*/
|
|
552
|
+
async commentList(aid, n = 10) {
|
|
553
|
+
if (!aid) return [];
|
|
554
|
+
const json = await this.call('api', '/x/v2/reply/wbi/main', { wbi: true, params: { type: 1, oid: aid, mode: 3, ps: 20 } });
|
|
555
|
+
return (json.data?.replies ?? [])
|
|
556
|
+
.map((r) => ({
|
|
557
|
+
rpid: String(r?.rpid ?? ''),
|
|
558
|
+
mid: String(r?.mid ?? ''),
|
|
559
|
+
name: String(r?.member?.uname ?? '').slice(0, 20),
|
|
560
|
+
content: String(r?.content?.message ?? '').replace(/\s+/g, ' ').slice(0, 120),
|
|
561
|
+
like: Number(r?.like ?? 0)
|
|
562
|
+
}))
|
|
563
|
+
.filter((x) => x.rpid && x.mid && x.content !== '')
|
|
564
|
+
.slice(0, n);
|
|
565
|
+
}
|
|
566
|
+
|
|
549
567
|
async hotComments(aid, n = 3) {
|
|
550
568
|
if (!aid) return [];
|
|
551
569
|
const json = await this.call('api', '/x/v2/reply/wbi/main', { wbi: true, params: { type: 1, oid: aid, mode: 3, ps: 5 } });
|
|
@@ -653,7 +671,7 @@ export class BiliClient {
|
|
|
653
671
|
*/
|
|
654
672
|
async atFeed() {
|
|
655
673
|
const json = await this.call('api', '/x/msgfeed/at', { params: { platform: 'web', build: 0, mobi_app: 'web' }, referer: 'https://message.bilibili.com/' });
|
|
656
|
-
|
|
674
|
+
const list = (json.data?.items ?? []).map((it) => {
|
|
657
675
|
const x = it?.item ?? {};
|
|
658
676
|
return {
|
|
659
677
|
id: it?.id === undefined || it?.id === null ? '' : `at:${it.id}`,
|
|
@@ -664,6 +682,8 @@ export class BiliClient {
|
|
|
664
682
|
oid: String(x.subject_id ?? ''),
|
|
665
683
|
rpid: String(x.source_id ?? ''),
|
|
666
684
|
root: String(x.root_id && String(x.root_id) !== '0' ? x.root_id : x.source_id ?? ''),
|
|
685
|
+
// 他是「回复某条评论」时 @ 她的吗?target_id 指向被回复的那条(0 = 顶层新评论)
|
|
686
|
+
targetId: String(x.target_id ?? '0'),
|
|
667
687
|
title: String(x.title ?? ''),
|
|
668
688
|
content: String(x.source_content ?? ''),
|
|
669
689
|
target: '',
|
|
@@ -672,6 +692,36 @@ export class BiliClient {
|
|
|
672
692
|
isComment: String(x.type ?? '') === 'reply'
|
|
673
693
|
};
|
|
674
694
|
}).filter((r) => r.id !== '' && r.isComment && r.oid !== '' && r.rpid !== '' && r.type > 0);
|
|
695
|
+
// 「回复某条评论 + @ 她」时,把她被回的那条原文也带上(顶层 @ 没有这条,跳过)
|
|
696
|
+
for (const it of list) {
|
|
697
|
+
if (it.targetId === '' || it.targetId === '0' || it.targetId === it.rpid) continue;
|
|
698
|
+
const text = await this.commentText({ type: it.type, oid: it.oid, root: it.root, rpid: it.targetId });
|
|
699
|
+
if (text !== '') it.target = text;
|
|
700
|
+
}
|
|
701
|
+
return list;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** 取一条评论的原文(被 @ 时用来还原"他回的是谁"),拿不到返回空串。 */
|
|
705
|
+
async commentText({ type, oid, root, rpid }) {
|
|
706
|
+
const flat = (s) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
707
|
+
const want = (r) => (r && String(r.rpid) === String(rpid) ? flat(`${r?.member?.uname ?? ''}:${r?.content?.message ?? ''}`) : '');
|
|
708
|
+
try {
|
|
709
|
+
for (const pn of [1, 2]) {
|
|
710
|
+
const json = await this.call('api', '/x/v2/reply/reply', {
|
|
711
|
+
params: { type, oid, root, ps: 50, pn },
|
|
712
|
+
referer: 'https://www.bilibili.com/'
|
|
713
|
+
});
|
|
714
|
+
const data = json?.data ?? {};
|
|
715
|
+
const hitRoot = want(data.root);
|
|
716
|
+
if (hitRoot !== '') return hitRoot;
|
|
717
|
+
for (const r of data.replies ?? []) {
|
|
718
|
+
const hit = want(r);
|
|
719
|
+
if (hit !== '') return hit;
|
|
720
|
+
}
|
|
721
|
+
if ((data.replies ?? []).length < 50) break;
|
|
722
|
+
}
|
|
723
|
+
} catch { /* 拿不到就当没有 */ }
|
|
724
|
+
return '';
|
|
675
725
|
}
|
|
676
726
|
|
|
677
727
|
/** 在评论区回复一条评论(root = 楼主评论,parent = 要回的那条)。返回新评论 id。 */
|