dsh-foxy-weibo 0.1.0 → 0.2.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.
- package/README.md +28 -13
- package/cordis.patch.yml +6 -1
- package/lib/client.js +150 -24
- package/lib/index.js +302 -7
- package/lib/judge.js +16 -7
- package/lib/login.js +185 -0
- package/lib/settings.js +30 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,12 +6,13 @@ Foxy 的微博子插件:按 foxy3 预设关注微博账号、阅读博文、
|
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
9
|
+
- **主模式**:检查自己的微博 UID,读取自己新博文下的评论,只回复未回复的他人评论;
|
|
10
|
+
- 可设置每日检查时间(`HH:MM`,最多 6 个),例如 `08:30, 20:00`;
|
|
11
|
+
- 回复前按 foxy3 预设判断 `skip / comment`,不设最少字数,最多 80 字;
|
|
12
|
+
- 旧模式保留:关注一个或多个微博 UID,定时读取时间线并评论;
|
|
13
|
+
- 可选自动回复/评论,支持 `replyOthers`、每日上限;
|
|
14
|
+
- 浏览、评论、回复事件写入 Foxy 记忆,字段包含 `platform: weibo`、`channelType: weibo_post|weibo_comment`、`eventType: weibo_browse|weibo_comment|weibo_reply`;
|
|
15
|
+
- 设置页提供扫码登录、Cookie 备用、自己的 UID、关注 UID、模型、每日时间、回复模式、调试日志与立即运行。
|
|
15
16
|
|
|
16
17
|
## 安装
|
|
17
18
|
|
|
@@ -23,9 +24,20 @@ dsh plugin --profile desktop add file:D:\Documents\deepseek-harness\default-work
|
|
|
23
24
|
|
|
24
25
|
## 配置
|
|
25
26
|
|
|
27
|
+
### 扫码登录
|
|
28
|
+
|
|
29
|
+
设置页点「扫码登录」,用微博 App 扫码确认即可。插件会:
|
|
30
|
+
|
|
31
|
+
1. 获取 `login.sina.com.cn` 的二维码;
|
|
32
|
+
2. 轮询扫码状态;
|
|
33
|
+
3. 登录成功后合并 `SUB/SUBP/XSRF-TOKEN` 等 Set-Cookie;
|
|
34
|
+
4. 自动填写账号 UID,并把 Cookie 保存到本机。
|
|
35
|
+
|
|
36
|
+
二维码过期时点「刷新二维码」。也可以继续手动填 Cookie 作为备用。
|
|
37
|
+
|
|
26
38
|
### 微博 Cookie
|
|
27
39
|
|
|
28
|
-
读取公开时间线在部分网络环境会触发微博风控(HTTP 432
|
|
40
|
+
读取公开时间线在部分网络环境会触发微博风控(HTTP 432),因此建议登录:
|
|
29
41
|
|
|
30
42
|
- `SUB`
|
|
31
43
|
- `SUBP`
|
|
@@ -33,9 +45,11 @@ dsh plugin --profile desktop add file:D:\Documents\deepseek-harness\default-work
|
|
|
33
45
|
|
|
34
46
|
Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前端。插件优先使用 `m.weibo.cn`,失败时用 `weibo.com/ajax/statuses/mymblog` 作为备用接口。
|
|
35
47
|
|
|
36
|
-
### 关注 UID
|
|
48
|
+
### 自己的 UID / 关注 UID
|
|
49
|
+
|
|
50
|
+
`自己的微博 UID` 是主模式使用的账号,插件只检查它的新博文和评论。
|
|
37
51
|
|
|
38
|
-
|
|
52
|
+
`关注 UID` 是旧模式使用,多个 UID 用逗号分隔,最多 20 个。UID 是微博用户主页 URL 里的数字 ID,例如 `https://weibo.com/u/1234567890`。
|
|
39
53
|
|
|
40
54
|
### AI 模型
|
|
41
55
|
|
|
@@ -49,10 +63,10 @@ Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前
|
|
|
49
63
|
|
|
50
64
|
## 建议顺序
|
|
51
65
|
|
|
52
|
-
1.
|
|
53
|
-
2.
|
|
54
|
-
3. 确认判断正常后,再开启 `
|
|
55
|
-
4.
|
|
66
|
+
1. 扫码登录,填自己的微博 UID,`enabled: true`、`replyMode: off`;
|
|
67
|
+
2. 设置每日检查时间,或先点「立即运行」,看日志和「调试」里的模型决策;
|
|
68
|
+
3. 确认判断正常后,再开启 `replyMode: auto`,每日上限先设为 1;
|
|
69
|
+
4. 「立即检查」只判断不发布,用来预览回复;「立即回复」会真实发布一条,适合测试;
|
|
56
70
|
5. 微博评论有账号风控,不要高频、不要对陌生人全自动回复。
|
|
57
71
|
|
|
58
72
|
## 记忆事件
|
|
@@ -61,6 +75,7 @@ Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前
|
|
|
61
75
|
|
|
62
76
|
- 浏览:`我在微博看到 <博主> 的博文「...」,觉得...`
|
|
63
77
|
- 评论:`我在微博评论了 <博主> 的博文「...」:...`
|
|
78
|
+
- 回复:`我在微博回复了 <网友> 在自己博文「...」下的评论:...`
|
|
64
79
|
|
|
65
80
|
事件字段:
|
|
66
81
|
|
package/cordis.patch.yml
CHANGED
|
@@ -10,12 +10,17 @@
|
|
|
10
10
|
config:
|
|
11
11
|
enabled: false
|
|
12
12
|
cookie: ''
|
|
13
|
+
selfUid: ''
|
|
13
14
|
targetUids: []
|
|
15
|
+
replyOwnPosts: true
|
|
16
|
+
replyMode: 'off'
|
|
17
|
+
scheduleTimes:
|
|
18
|
+
- '20:00'
|
|
14
19
|
intervalMinutes: 60
|
|
15
20
|
maxPostsPerRun: 5
|
|
16
21
|
commentMode: 'off'
|
|
17
22
|
maxCommentsPerDay: 1
|
|
18
|
-
replyOthers:
|
|
23
|
+
replyOthers: true
|
|
19
24
|
enableMemory: true
|
|
20
25
|
memoryPresetId: 'foxy3(Character)'
|
|
21
26
|
llmBaseUrl: 'https://api.deepseek.com'
|
package/lib/client.js
CHANGED
|
@@ -43,6 +43,9 @@ window.__ModuleLoader__.load({
|
|
|
43
43
|
.fw-toggle strong { display: block; font-size: 12px; }
|
|
44
44
|
.fw-toggle small { display: block; margin-top: 2px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; }
|
|
45
45
|
.fw-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
|
46
|
+
.fw-qr { display: flex; align-items: center; gap: 14px; margin-top: 12px; padding: 10px; border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 8px; }
|
|
47
|
+
.fw-qr img { display: block; flex: none; width: 180px; height: 180px; background: #fff; }
|
|
48
|
+
.fw-qr p { margin: 0; color: var(--dsw-alias-label-secondary, #4b5563); font-size: 12px; }
|
|
46
49
|
.fw-btn { border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 7px; padding: 7px 12px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
|
|
47
50
|
.fw-btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
48
51
|
.fw-btn:disabled { opacity: .55; cursor: default; }
|
|
@@ -97,6 +100,10 @@ window.__ModuleLoader__.load({
|
|
|
97
100
|
const [cookieDraft, setCookieDraft] = React.useState('')
|
|
98
101
|
const [apiKeyDraft, setApiKeyDraft] = React.useState('')
|
|
99
102
|
const [targetText, setTargetText] = React.useState('')
|
|
103
|
+
const [scheduleText, setScheduleText] = React.useState('')
|
|
104
|
+
const [qr, setQr] = React.useState('')
|
|
105
|
+
const [qrState, setQrState] = React.useState('')
|
|
106
|
+
const qrTimer = React.useRef(null)
|
|
100
107
|
const [busy, setBusy] = React.useState('')
|
|
101
108
|
const [error, setError] = React.useState('')
|
|
102
109
|
const [notice, setNotice] = React.useState('')
|
|
@@ -108,12 +115,16 @@ window.__ModuleLoader__.load({
|
|
|
108
115
|
setSettings(data.settings)
|
|
109
116
|
setStatus(data.status)
|
|
110
117
|
setTargetText((data.settings?.targetUids || []).join(', '))
|
|
118
|
+
setScheduleText((data.settings?.scheduleTimes || []).join(', '))
|
|
111
119
|
}, [])
|
|
112
120
|
|
|
113
121
|
React.useEffect(() => {
|
|
114
122
|
mounted.current = true
|
|
115
123
|
refresh().catch((e) => setError(e.message))
|
|
116
|
-
return () => {
|
|
124
|
+
return () => {
|
|
125
|
+
mounted.current = false
|
|
126
|
+
if (qrTimer.current) clearTimeout(qrTimer.current)
|
|
127
|
+
}
|
|
117
128
|
}, [refresh])
|
|
118
129
|
|
|
119
130
|
const setField = (key, value) => setSettings((current) => (current ? { ...current, [key]: value } : current))
|
|
@@ -122,7 +133,7 @@ window.__ModuleLoader__.load({
|
|
|
122
133
|
if (!settings) return
|
|
123
134
|
setBusy('save'); setError(''); setNotice('')
|
|
124
135
|
try {
|
|
125
|
-
const patch = { ...settings, targetUids: targetText }
|
|
136
|
+
const patch = { ...settings, targetUids: targetText, scheduleTimes: scheduleText }
|
|
126
137
|
if (cookieDraft.trim()) patch.cookie = cookieDraft.trim()
|
|
127
138
|
if (apiKeyDraft.trim()) patch.llmApiKey = apiKeyDraft.trim()
|
|
128
139
|
const data = await request('/settings', { method: 'POST', body: JSON.stringify(patch) })
|
|
@@ -134,6 +145,68 @@ window.__ModuleLoader__.load({
|
|
|
134
145
|
} catch (e) { setError(e.message) } finally { setBusy('') }
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
const pollLogin = async (qrid, attempt = 0) => {
|
|
149
|
+
if (!mounted.current || attempt > 90) return
|
|
150
|
+
try {
|
|
151
|
+
const data = await request('/login/poll', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
body: JSON.stringify({ qrid }),
|
|
154
|
+
})
|
|
155
|
+
if (!mounted.current) return
|
|
156
|
+
if (data.state === 'ok') {
|
|
157
|
+
setQrState('ok')
|
|
158
|
+
setQr('')
|
|
159
|
+
setSettings(data.settings)
|
|
160
|
+
setStatus(data.status)
|
|
161
|
+
setNotice(`已登录${data.account?.screenName ? `:${data.account.screenName}` : ''}`)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (data.state === 'expired') {
|
|
165
|
+
setQrState('expired')
|
|
166
|
+
setQr('')
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
setQrState(data.state)
|
|
170
|
+
qrTimer.current = setTimeout(() => pollLogin(qrid, attempt + 1), 2000)
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (mounted.current) setError(e.message)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const startLogin = async () => {
|
|
177
|
+
if (qrTimer.current) clearTimeout(qrTimer.current)
|
|
178
|
+
setBusy('login')
|
|
179
|
+
setError('')
|
|
180
|
+
setNotice('')
|
|
181
|
+
try {
|
|
182
|
+
const data = await request('/login/start', { method: 'POST', body: '{}' })
|
|
183
|
+
setQr(data.qr)
|
|
184
|
+
setQrState('waiting')
|
|
185
|
+
pollLogin(data.qrid)
|
|
186
|
+
} catch (e) {
|
|
187
|
+
setError(e.message)
|
|
188
|
+
} finally {
|
|
189
|
+
setBusy('')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const logout = async () => {
|
|
194
|
+
setBusy('logout')
|
|
195
|
+
setError('')
|
|
196
|
+
setNotice('')
|
|
197
|
+
try {
|
|
198
|
+
const data = await request('/logout', { method: 'POST', body: '{}' })
|
|
199
|
+
setSettings(data.settings)
|
|
200
|
+
setStatus(data.status)
|
|
201
|
+
setQr('')
|
|
202
|
+
setNotice('已退出微博登录')
|
|
203
|
+
} catch (e) {
|
|
204
|
+
setError(e.message)
|
|
205
|
+
} finally {
|
|
206
|
+
setBusy('')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
137
210
|
const runNow = async () => {
|
|
138
211
|
setBusy('run'); setError(''); setNotice('')
|
|
139
212
|
try {
|
|
@@ -145,7 +218,45 @@ window.__ModuleLoader__.load({
|
|
|
145
218
|
? '未开始:自动浏览已关闭'
|
|
146
219
|
: result.skipped === 'busy'
|
|
147
220
|
? '正在运行,请稍候'
|
|
148
|
-
:
|
|
221
|
+
: result.skipped === 'no-self'
|
|
222
|
+
? '未开始:请先填写自己的微博 UID'
|
|
223
|
+
: `运行完成:检查 ${result.checked || 0} 条,回复 ${result.replied || 0} 条` +
|
|
224
|
+
(result.previewed ? `,预览 ${result.previewed} 条` : '') +
|
|
225
|
+
`,跳过 ${result.skipped || 0} 条`,
|
|
226
|
+
)
|
|
227
|
+
} catch (e) { setError(e.message) } finally { setBusy('') }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const checkNow = async () => {
|
|
231
|
+
setBusy('check'); setError(''); setNotice('')
|
|
232
|
+
try {
|
|
233
|
+
const data = await request('/check-now', { method: 'POST', body: '{}' })
|
|
234
|
+
setStatus(data.status)
|
|
235
|
+
const result = data.result || {}
|
|
236
|
+
setNotice(
|
|
237
|
+
result.skipped === 'no-self'
|
|
238
|
+
? '未开始:请先扫码登录或填写自己的微博 UID'
|
|
239
|
+
: result.skipped === 'busy'
|
|
240
|
+
? '正在运行,请稍候'
|
|
241
|
+
: `检查完成:检查 ${result.checked || 0} 条评论,预览 ${result.previewed || 0} 条,跳过 ${result.skipped || 0} 条` +
|
|
242
|
+
(result.errors ? `,失败 ${result.errors} 条` : ''),
|
|
243
|
+
)
|
|
244
|
+
} catch (e) { setError(e.message) } finally { setBusy('') }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const replyNow = async () => {
|
|
248
|
+
if (!window.confirm('立即回复会真实发布到微博,确定继续?')) return
|
|
249
|
+
setBusy('reply'); setError(''); setNotice('')
|
|
250
|
+
try {
|
|
251
|
+
const data = await request('/reply-now', { method: 'POST', body: '{}' })
|
|
252
|
+
setStatus(data.status)
|
|
253
|
+
const result = data.result || {}
|
|
254
|
+
setNotice(
|
|
255
|
+
result.replied > 0
|
|
256
|
+
? `已立即回复 ${result.replied} 条,可在下方日志查看`
|
|
257
|
+
: result.skipped === 'no-self'
|
|
258
|
+
? '未开始:请先扫码登录或填写自己的微博 UID'
|
|
259
|
+
: '没有可立即回复的评论(可能已处理或模型选择跳过)',
|
|
149
260
|
)
|
|
150
261
|
} catch (e) { setError(e.message) } finally { setBusy('') }
|
|
151
262
|
}
|
|
@@ -168,23 +279,26 @@ window.__ModuleLoader__.load({
|
|
|
168
279
|
),
|
|
169
280
|
|
|
170
281
|
h('section', { className: 'fw-card' },
|
|
171
|
-
h(SectionHead, { title: '
|
|
172
|
-
h(Field, { label: '微博 Cookie', hint: settings.cookieSet ? '已保存;留空表示不修改' : '从浏览器复制 SUB、SUBP、XSRF-TOKEN 等 Cookie' },
|
|
173
|
-
h('textarea', { value: cookieDraft, placeholder: settings.cookieSet ? '已保存,留空不修改' : 'SUB=...; SUBP=...; XSRF-TOKEN=...', onChange: (e) => setCookieDraft(e.target.value) })),
|
|
282
|
+
h(SectionHead, { title: '账号与登录', description: '扫码登录后即可回复评论;Cookie 输入保留为备用方式。' }),
|
|
174
283
|
h('div', { className: 'fw-grid' },
|
|
175
|
-
h(Field, { label: '
|
|
176
|
-
h('input', { value:
|
|
177
|
-
h(Field, { label: '当前状态', hint: '登录与目标数量' },
|
|
178
|
-
h('input', { value: settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
|
|
284
|
+
h(Field, { label: '当前账号', hint: '扫码成功后自动显示' },
|
|
285
|
+
h('input', { value: settings.accountName ? `${settings.accountName}(${settings.accountUid || '?'})` : settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
|
|
179
286
|
),
|
|
287
|
+
h(Field, { label: '自己的微博 UID', hint: '主功能会检查这个账号的新博文评论' },
|
|
288
|
+
h('input', { value: settings.selfUid || '', placeholder: '例如 1234567890', onChange: (e) => setField('selfUid', e.target.value) })),
|
|
180
289
|
),
|
|
181
290
|
h('div', { className: 'fw-actions' },
|
|
182
|
-
h('button', { className: 'fw-btn', disabled: !!busy, onClick:
|
|
183
|
-
h('button', { className: 'fw-btn', disabled: !!busy || !settings.cookieSet, onClick:
|
|
184
|
-
setBusy('logout'); setError(''); setNotice('')
|
|
185
|
-
try { const data = await request('/settings', { method: 'POST', body: JSON.stringify({ clearCookie: true }) }); setSettings(data.settings); setStatus(data.status); setNotice('已清除 Cookie') } catch (e) { setError(e.message) } finally { setBusy('') }
|
|
186
|
-
} }, '退出登录'),
|
|
291
|
+
h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: startLogin }, busy === 'login' ? '获取二维码…' : qr ? '刷新二维码' : '扫码登录'),
|
|
292
|
+
h('button', { className: 'fw-btn', disabled: !!busy || !settings.cookieSet, onClick: logout }, busy === 'logout' ? '退出中…' : '退出登录'),
|
|
187
293
|
),
|
|
294
|
+
qr
|
|
295
|
+
? h('div', { className: 'fw-qr' },
|
|
296
|
+
h('img', { src: qr, alt: '微博登录二维码', width: 180, height: 180 }),
|
|
297
|
+
h('p', null, qrState === 'scanned' ? '已扫码,请在手机上确认登录' : qrState === 'expired' ? '二维码已过期,请刷新' : '请用微博 App 扫码'),
|
|
298
|
+
)
|
|
299
|
+
: null,
|
|
300
|
+
h(Field, { label: '微博 Cookie(备用)', hint: settings.cookieSet ? '已保存;留空表示不修改' : '从浏览器复制 SUB、SUBP、XSRF-TOKEN 等 Cookie' },
|
|
301
|
+
h('textarea', { value: cookieDraft, placeholder: settings.cookieSet ? '已保存,留空不修改' : 'SUB=...; SUBP=...; XSRF-TOKEN=...', onChange: (e) => setCookieDraft(e.target.value) })),
|
|
188
302
|
),
|
|
189
303
|
|
|
190
304
|
h('section', { className: 'fw-card' },
|
|
@@ -200,27 +314,39 @@ window.__ModuleLoader__.load({
|
|
|
200
314
|
),
|
|
201
315
|
|
|
202
316
|
h('section', { className: 'fw-card' },
|
|
203
|
-
h(SectionHead, { title: '
|
|
204
|
-
h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '
|
|
317
|
+
h(SectionHead, { title: '回复自己的博文', description: '主功能:每天按设定时间检查自己的新博文,只回复未回复的他人评论。' }),
|
|
318
|
+
h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动检查', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
|
|
319
|
+
h(Toggle, { checked: settings.replyOwnPosts, onChange: (v) => setField('replyOwnPosts', v), label: '主模式:只看自己的博文评论', hint: '关闭后回到旧模式,浏览关注账号的时间线并评论博文。' }),
|
|
205
320
|
h('div', { className: 'fw-grid' },
|
|
206
|
-
h(Field, { label: '
|
|
207
|
-
h('input', {
|
|
208
|
-
h(Field, { label: '
|
|
321
|
+
h(Field, { label: '每日检查时间', hint: 'HH:MM,多个用逗号分隔,最多 6 个,例如 08:30, 20:00' },
|
|
322
|
+
h('input', { value: scheduleText, placeholder: '20:00', onChange: (e) => setScheduleText(e.target.value) })),
|
|
323
|
+
h(Field, { label: '回复模式', hint: '自动才会发布回复;建议先仅记录' },
|
|
324
|
+
h('select', { value: settings.replyMode, onChange: (e) => setField('replyMode', e.target.value) },
|
|
325
|
+
h('option', { value: 'off' }, '仅检查,不回复'),
|
|
326
|
+
h('option', { value: 'auto' }, '自动回复'),
|
|
327
|
+
)),
|
|
328
|
+
h(Field, { label: '每轮检查博文数', hint: '范围 1–20' },
|
|
209
329
|
h('input', { type: 'number', min: 1, max: 20, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
|
|
210
|
-
h(Field, { label: '
|
|
330
|
+
h(Field, { label: '每日回复上限', hint: '0–10,同时限制评论' },
|
|
331
|
+
h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
|
|
332
|
+
),
|
|
333
|
+
h('div', { className: 'fw-grid' },
|
|
334
|
+
h(Field, { label: '时间线轮询(旧模式备用)', hint: '关闭主模式时使用,分钟' },
|
|
335
|
+
h('input', { type: 'number', min: 5, max: 1440, value: settings.intervalMinutes, onChange: (e) => setField('intervalMinutes', e.target.value) })),
|
|
336
|
+
h(Field, { label: '旧模式评论模式', hint: '浏览他人博文时是否发一级评论' },
|
|
211
337
|
h('select', { value: settings.commentMode, onChange: (e) => setField('commentMode', e.target.value) },
|
|
212
338
|
h('option', { value: 'off' }, '仅记录'),
|
|
213
339
|
h('option', { value: 'auto' }, '自动评论'),
|
|
214
340
|
)),
|
|
215
|
-
h(Field, { label: '每日评论上限', hint: '0–10,仅限制自动评论' },
|
|
216
|
-
h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
|
|
217
341
|
),
|
|
218
|
-
h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '
|
|
342
|
+
h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '旧模式可从评论区选择一条评论回复。' }),
|
|
219
343
|
h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '写入 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
|
|
220
344
|
h(Toggle, { checked: settings.runOnStart, onChange: (v) => setField('runOnStart', v), label: '启动 DSH 后自动运行', hint: '启动约 20 秒后执行一轮。' }),
|
|
221
345
|
h(Toggle, { checked: settings.debug, onChange: (v) => setField('debug', v), label: '记录调试日志', hint: '保存模型输出与记忆写入结果,便于排查。' }),
|
|
222
346
|
h('div', { className: 'fw-actions' },
|
|
223
347
|
h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: runNow }, busy === 'run' ? '正在运行…' : '立即运行'),
|
|
348
|
+
h('button', { className: 'fw-btn', disabled: !!busy, onClick: checkNow }, busy === 'check' ? '检查中…' : '立即检查'),
|
|
349
|
+
h('button', { className: 'fw-btn', disabled: !!busy, onClick: replyNow }, busy === 'reply' ? '回复中…' : '立即回复'),
|
|
224
350
|
h('button', { className: 'fw-btn', disabled: !!busy, onClick: save }, busy === 'save' ? '保存中…' : '保存设置'),
|
|
225
351
|
),
|
|
226
352
|
h('div', { className: 'fw-stats' },
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import { fetchComments, fetchTimeline, postComment, postSummary
|
|
3
|
+
import { fetchComments, fetchTimeline, postComment, postSummary } from './weibo.js'
|
|
4
4
|
import { judgePost } from './judge.js'
|
|
5
|
+
import { fetchAccount, loginPoll, loginStart } from './login.js'
|
|
5
6
|
import { DEFAULTS, foxyHome, loadSettings, normalizePatch, publicSettings, saveSettings } from './settings.js'
|
|
6
7
|
|
|
7
8
|
export const name = 'dsh-foxy-weibo'
|
|
@@ -10,6 +11,11 @@ export const inject = ['connection']
|
|
|
10
11
|
export const STATUS_ROUTE = '/api/dsh-foxy-weibo/status'
|
|
11
12
|
export const SETTINGS_ROUTE = '/api/dsh-foxy-weibo/settings'
|
|
12
13
|
export const RUN_ROUTE = '/api/dsh-foxy-weibo/run'
|
|
14
|
+
export const CHECK_NOW_ROUTE = '/api/dsh-foxy-weibo/check-now'
|
|
15
|
+
export const REPLY_NOW_ROUTE = '/api/dsh-foxy-weibo/reply-now'
|
|
16
|
+
export const LOGIN_START_ROUTE = '/api/dsh-foxy-weibo/login/start'
|
|
17
|
+
export const LOGIN_POLL_ROUTE = '/api/dsh-foxy-weibo/login/poll'
|
|
18
|
+
export const LOGOUT_ROUTE = '/api/dsh-foxy-weibo/logout'
|
|
13
19
|
|
|
14
20
|
const ACTION_LABELS = { skip: '跳过', remember: '记录', comment: '评论', error: '失败' }
|
|
15
21
|
|
|
@@ -22,6 +28,8 @@ function loadState() {
|
|
|
22
28
|
const parsed = JSON.parse(readFileSync(statePath(), 'utf8'))
|
|
23
29
|
return {
|
|
24
30
|
seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
|
|
31
|
+
commentSeen:
|
|
32
|
+
parsed.commentSeen && typeof parsed.commentSeen === 'object' ? parsed.commentSeen : {},
|
|
25
33
|
days: parsed.days && typeof parsed.days === 'object' ? parsed.days : {},
|
|
26
34
|
history: Array.isArray(parsed.history) ? parsed.history.slice(0, 50) : [],
|
|
27
35
|
debug: Array.isArray(parsed.debug) ? parsed.debug.slice(0, 80) : [],
|
|
@@ -29,7 +37,7 @@ function loadState() {
|
|
|
29
37
|
lastResult: parsed.lastResult || null,
|
|
30
38
|
}
|
|
31
39
|
} catch {
|
|
32
|
-
return { seen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
|
|
40
|
+
return { seen: {}, commentSeen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -120,6 +128,181 @@ async function remember(ctx, config, state, post, decision, extraText = '') {
|
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
|
|
131
|
+
async function rememberReply(ctx, config, state, post, comment, replyText) {
|
|
132
|
+
if (!config.enableMemory) return { ok: false, reason: 'disabled' }
|
|
133
|
+
const provider = memoryProvider(ctx)
|
|
134
|
+
if (!provider?.record) return { ok: false, reason: 'no-provider' }
|
|
135
|
+
const person = comment.author?.name || comment.author?.uid || '一位网友'
|
|
136
|
+
const text = `我在微博回复了 ${person} 在自己博文「${firstLine(post.text, 60)}」下的评论:${replyText}`
|
|
137
|
+
try {
|
|
138
|
+
await provider.record({
|
|
139
|
+
role: 'assistant',
|
|
140
|
+
text,
|
|
141
|
+
sessionId: `weibo-comment:${post.id}:${comment.cid}`,
|
|
142
|
+
presetId: config.memoryPresetId || 'foxy3(Character)',
|
|
143
|
+
platform: 'weibo',
|
|
144
|
+
sourceLabel: '微博评论回复',
|
|
145
|
+
eventType: 'weibo_reply',
|
|
146
|
+
channelType: 'weibo_comment',
|
|
147
|
+
channelId: String(comment.cid),
|
|
148
|
+
title: firstLine(post.text, 120),
|
|
149
|
+
url: post.url,
|
|
150
|
+
scene: 'weibo',
|
|
151
|
+
})
|
|
152
|
+
debugLog(state, config, { kind: 'memory', post: post.id, memory: 'written', text: firstLine(text, 300) })
|
|
153
|
+
return { ok: true, reason: 'written', text }
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const reason = error?.message || String(error)
|
|
156
|
+
debugLog(state, config, { kind: 'memory', post: post.id, memory: 'error', reason })
|
|
157
|
+
return { ok: false, reason }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function scanOwnComments(ctx, config, state, log, force = false, options = {}) {
|
|
162
|
+
if (!config.enabled && !force) return { skipped: 'disabled' }
|
|
163
|
+
const previewOnly = options.previewOnly === true
|
|
164
|
+
const forceReply = options.forceReply === true
|
|
165
|
+
const canReply = !previewOnly && (forceReply || config.replyMode === 'auto')
|
|
166
|
+
const targetCid = String(options.cid || '').trim()
|
|
167
|
+
const selfUid = String(config.selfUid || config.accountUid || '').trim()
|
|
168
|
+
if (!selfUid) return { skipped: 'no-self' }
|
|
169
|
+
const maxPosts = Math.max(1, Math.min(20, Number(config.maxPostsPerRun) || 5))
|
|
170
|
+
const maxReplies = Math.max(1, Math.min(8, Number(options.maxReplies ?? config.maxCommentsPerDay) || 1))
|
|
171
|
+
const ignoreQuota = forceReply
|
|
172
|
+
const result = {
|
|
173
|
+
mode: options.mode || 'own-comments',
|
|
174
|
+
checked: 0,
|
|
175
|
+
replied: 0,
|
|
176
|
+
previewed: 0,
|
|
177
|
+
skipped: 0,
|
|
178
|
+
errors: 0,
|
|
179
|
+
items: [],
|
|
180
|
+
}
|
|
181
|
+
let posts = []
|
|
182
|
+
try {
|
|
183
|
+
posts = await fetchTimeline(selfUid, config)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { ...result, errors: 1, error: error?.message || String(error) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const post of posts.slice(0, maxPosts)) {
|
|
189
|
+
if (!post.id) continue
|
|
190
|
+
let comments = []
|
|
191
|
+
try {
|
|
192
|
+
comments = await fetchComments(post.mid, config, 30)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
result.errors += 1
|
|
195
|
+
log(`[foxy-weibo] 读取评论失败 ${post.id}:${error?.message || error}`)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
for (const comment of comments) {
|
|
199
|
+
if (targetCid && String(comment.cid) !== targetCid) continue
|
|
200
|
+
if (canReply && result.replied >= maxReplies) break
|
|
201
|
+
if (!comment.cid || state.commentSeen[comment.cid]) continue
|
|
202
|
+
if (String(comment.author?.uid || '') === selfUid) {
|
|
203
|
+
state.commentSeen[comment.cid] = { at: Date.now(), status: 'self' }
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const limit = Math.max(0, Number(config.maxCommentsPerDay) || 0)
|
|
207
|
+
if (canReply && !ignoreQuota && commentCount(state) >= limit) break
|
|
208
|
+
result.checked += 1
|
|
209
|
+
let entry
|
|
210
|
+
try {
|
|
211
|
+
const decision = await judgePost(config, {
|
|
212
|
+
summary: postSummary(post),
|
|
213
|
+
comments,
|
|
214
|
+
commentsText: commentsTextOf(comments),
|
|
215
|
+
targetComment: {
|
|
216
|
+
cid: String(comment.cid),
|
|
217
|
+
text: comment.text,
|
|
218
|
+
author: comment.author,
|
|
219
|
+
},
|
|
220
|
+
mayComment: true,
|
|
221
|
+
mayReply: true,
|
|
222
|
+
})
|
|
223
|
+
debugLog(state, config, {
|
|
224
|
+
kind: 'judge-own-comment',
|
|
225
|
+
post: post.id,
|
|
226
|
+
cid: String(comment.cid),
|
|
227
|
+
action: decision.action,
|
|
228
|
+
raw: String(decision.raw || '').slice(0, 500),
|
|
229
|
+
})
|
|
230
|
+
if (decision.action !== 'comment' || !decision.comment) {
|
|
231
|
+
result.skipped += 1
|
|
232
|
+
state.commentSeen[comment.cid] = { at: Date.now(), status: 'skipped' }
|
|
233
|
+
entry = {
|
|
234
|
+
at: Date.now(),
|
|
235
|
+
post: post.id,
|
|
236
|
+
cid: String(comment.cid),
|
|
237
|
+
author: comment.author?.name || comment.author?.uid,
|
|
238
|
+
title: firstLine(post.text, 100),
|
|
239
|
+
action: 'skip',
|
|
240
|
+
reason: decision.mood || '模型判断不需要回复',
|
|
241
|
+
memory: 'skipped',
|
|
242
|
+
}
|
|
243
|
+
} else if (!canReply) {
|
|
244
|
+
result.previewed += 1
|
|
245
|
+
entry = {
|
|
246
|
+
at: Date.now(),
|
|
247
|
+
post: post.id,
|
|
248
|
+
cid: String(comment.cid),
|
|
249
|
+
author: comment.author?.name || comment.author?.uid,
|
|
250
|
+
title: firstLine(post.text, 100),
|
|
251
|
+
action: 'preview',
|
|
252
|
+
comment: decision.comment,
|
|
253
|
+
reason: '回复模式为仅检查,未发布',
|
|
254
|
+
memory: 'skipped',
|
|
255
|
+
}
|
|
256
|
+
log(`[foxy-weibo] 仅检查 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
|
|
257
|
+
} else {
|
|
258
|
+
await postComment({
|
|
259
|
+
mid: post.mid,
|
|
260
|
+
text: decision.comment,
|
|
261
|
+
cid: String(comment.cid),
|
|
262
|
+
config,
|
|
263
|
+
})
|
|
264
|
+
bumpComments(state)
|
|
265
|
+
result.replied += 1
|
|
266
|
+
state.commentSeen[comment.cid] = { at: Date.now(), status: 'replied' }
|
|
267
|
+
const memory = await rememberReply(ctx, config, state, post, comment, decision.comment)
|
|
268
|
+
entry = {
|
|
269
|
+
at: Date.now(),
|
|
270
|
+
post: post.id,
|
|
271
|
+
cid: String(comment.cid),
|
|
272
|
+
author: comment.author?.name || comment.author?.uid,
|
|
273
|
+
title: firstLine(post.text, 100),
|
|
274
|
+
action: 'reply',
|
|
275
|
+
comment: decision.comment,
|
|
276
|
+
reason: firstLine(decision.comment, 140),
|
|
277
|
+
memory: memory.ok ? 'written' : memory.reason,
|
|
278
|
+
}
|
|
279
|
+
log(`[foxy-weibo] 回复 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
result.errors += 1
|
|
283
|
+
entry = {
|
|
284
|
+
at: Date.now(),
|
|
285
|
+
post: post.id,
|
|
286
|
+
cid: String(comment.cid),
|
|
287
|
+
author: comment.author?.name || comment.author?.uid,
|
|
288
|
+
title: firstLine(post.text, 100),
|
|
289
|
+
action: 'error',
|
|
290
|
+
reason: '回复评论时出错',
|
|
291
|
+
error: error?.message || String(error),
|
|
292
|
+
}
|
|
293
|
+
log(`[foxy-weibo] 回复评论 ${comment.cid} 失败:${error?.message || error}`)
|
|
294
|
+
}
|
|
295
|
+
if (entry) {
|
|
296
|
+
pushHistory(state, entry)
|
|
297
|
+
result.items.push(entry)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
state.lastRun = Date.now()
|
|
302
|
+
state.lastResult = result
|
|
303
|
+
return result
|
|
304
|
+
}
|
|
305
|
+
|
|
123
306
|
async function scanOnce(ctx, config, state, log, force = false) {
|
|
124
307
|
if (!config.enabled && !force) return { skipped: 'disabled' }
|
|
125
308
|
if (!config.targetUids.length) return { skipped: 'no-targets' }
|
|
@@ -282,7 +465,10 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
282
465
|
running = true
|
|
283
466
|
try {
|
|
284
467
|
log(`[foxy-weibo] 开始运行(${reason})`)
|
|
285
|
-
const result =
|
|
468
|
+
const result =
|
|
469
|
+
config.replyOwnPosts === false
|
|
470
|
+
? await scanOnce(ctx, config, state, log, force)
|
|
471
|
+
: await scanOwnComments(ctx, config, state, log, force)
|
|
286
472
|
saveState(state)
|
|
287
473
|
log(`[foxy-weibo] 运行结束 ${JSON.stringify(result)}`)
|
|
288
474
|
return result
|
|
@@ -295,17 +481,69 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
295
481
|
}
|
|
296
482
|
}
|
|
297
483
|
|
|
484
|
+
const checkNow = async () => {
|
|
485
|
+
if (running) return { skipped: 'busy' }
|
|
486
|
+
running = true
|
|
487
|
+
try {
|
|
488
|
+
log('[foxy-weibo] 开始立即检查')
|
|
489
|
+
const result = await scanOwnComments(ctx, config, state, log, true, {
|
|
490
|
+
previewOnly: true,
|
|
491
|
+
mode: 'own-comments-check',
|
|
492
|
+
})
|
|
493
|
+
saveState(state)
|
|
494
|
+
log(`[foxy-weibo] 立即检查结束 ${JSON.stringify(result)}`)
|
|
495
|
+
return result
|
|
496
|
+
} catch (error) {
|
|
497
|
+
saveState(state)
|
|
498
|
+
ctx.logger?.warn?.(`[foxy-weibo] check failed: ${error?.message || error}`)
|
|
499
|
+
return { error: String(error?.message || error) }
|
|
500
|
+
} finally {
|
|
501
|
+
running = false
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const replyNow = async (cid = '') => {
|
|
506
|
+
if (running) return { skipped: 'busy' }
|
|
507
|
+
running = true
|
|
508
|
+
try {
|
|
509
|
+
log(`[foxy-weibo] 开始立即回复${cid ? ` cid=${cid}` : ''}`)
|
|
510
|
+
const result = await scanOwnComments(ctx, config, state, log, true, {
|
|
511
|
+
forceReply: true,
|
|
512
|
+
maxReplies: 1,
|
|
513
|
+
cid,
|
|
514
|
+
mode: 'own-comments-reply-now',
|
|
515
|
+
})
|
|
516
|
+
saveState(state)
|
|
517
|
+
log(`[foxy-weibo] 立即回复结束 ${JSON.stringify(result)}`)
|
|
518
|
+
return result
|
|
519
|
+
} catch (error) {
|
|
520
|
+
saveState(state)
|
|
521
|
+
ctx.logger?.warn?.(`[foxy-weibo] reply-now failed: ${error?.message || error}`)
|
|
522
|
+
return { error: String(error?.message || error) }
|
|
523
|
+
} finally {
|
|
524
|
+
running = false
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
298
528
|
const status = () => ({
|
|
299
529
|
enabled: config.enabled === true,
|
|
300
530
|
running,
|
|
531
|
+
mode: config.replyOwnPosts === false ? 'timeline' : 'own-comments',
|
|
532
|
+
selfUid: config.selfUid || config.accountUid || '',
|
|
533
|
+
scheduleTimes: config.scheduleTimes,
|
|
534
|
+
nextRunAt,
|
|
535
|
+
replyMode: config.replyMode,
|
|
301
536
|
lastRun: state.lastRun,
|
|
302
537
|
lastResult: state.lastResult,
|
|
303
538
|
recent: state.history.slice(0, 12),
|
|
304
539
|
debugEnabled: config.debug === true,
|
|
305
540
|
debug: state.debug.slice(0, 40),
|
|
306
541
|
seen: Object.keys(state.seen).length,
|
|
542
|
+
commentsSeen: Object.keys(state.commentSeen || {}).length,
|
|
307
543
|
commentsToday: commentCount(state),
|
|
308
544
|
cookieSet: Boolean(config.cookie),
|
|
545
|
+
accountName: config.accountName || '',
|
|
546
|
+
accountUid: config.accountUid || '',
|
|
309
547
|
apiKeySet: Boolean(config.llmApiKey),
|
|
310
548
|
targetCount: config.targetUids.length,
|
|
311
549
|
})
|
|
@@ -315,14 +553,37 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
315
553
|
foxy?.registerCapability?.('weibo', async () => capability)
|
|
316
554
|
ctx.provide?.('foxyWeibo', capability)
|
|
317
555
|
|
|
556
|
+
let nextRunAt = 0
|
|
557
|
+
const nextDelay = () => {
|
|
558
|
+
const times = Array.isArray(config.scheduleTimes) ? config.scheduleTimes : []
|
|
559
|
+
if (!times.length) return Math.max(5, Number(config.intervalMinutes) || 60) * 60 * 1000
|
|
560
|
+
const now = new Date()
|
|
561
|
+
let best = Infinity
|
|
562
|
+
for (const text of times) {
|
|
563
|
+
const [hour, minute] = String(text).split(':').map(Number)
|
|
564
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute)) continue
|
|
565
|
+
for (let day = 0; day <= 1; day += 1) {
|
|
566
|
+
const at = new Date(now)
|
|
567
|
+
at.setDate(now.getDate() + day)
|
|
568
|
+
at.setHours(hour, minute, 0, 0)
|
|
569
|
+
const delay = at.getTime() - now.getTime()
|
|
570
|
+
if (delay > 0 && delay < best) best = delay
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return Number.isFinite(best) ? best : 60 * 60 * 1000
|
|
574
|
+
}
|
|
318
575
|
const restartTimer = () => {
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
576
|
+
clearTimeout(timer)
|
|
577
|
+
const delay = nextDelay()
|
|
578
|
+
nextRunAt = Date.now() + delay
|
|
579
|
+
timer = setTimeout(async () => {
|
|
580
|
+
await run('schedule').catch(() => {})
|
|
581
|
+
restartTimer()
|
|
582
|
+
}, delay)
|
|
322
583
|
timer.unref?.()
|
|
323
584
|
}
|
|
324
585
|
restartTimer()
|
|
325
|
-
ctx.effect?.(() => () =>
|
|
586
|
+
ctx.effect?.(() => () => clearTimeout(timer), 'foxy-weibo: scan timer')
|
|
326
587
|
|
|
327
588
|
if (config.runOnStart) {
|
|
328
589
|
startTimer = setTimeout(() => run('start').catch(() => {}), 20_000)
|
|
@@ -361,10 +622,44 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
361
622
|
}
|
|
362
623
|
return json({ ok: true, settings: publicSettings(config), status: status() })
|
|
363
624
|
})
|
|
625
|
+
route(LOGIN_START_ROUTE, ['POST'], async () => json({ ok: true, ...(await loginStart()) }))
|
|
626
|
+
|
|
627
|
+
route(LOGIN_POLL_ROUTE, ['POST'], async (request) => {
|
|
628
|
+
const { qrid } = await readJsonBody(request)
|
|
629
|
+
const result = await loginPoll(qrid)
|
|
630
|
+
if (result.state === 'ok' && result.cookie) {
|
|
631
|
+
config.cookie = result.cookie
|
|
632
|
+
config.accountName = result.account?.screenName || ''
|
|
633
|
+
config.accountUid = result.account?.uid || ''
|
|
634
|
+
if (!config.selfUid && result.account?.uid) config.selfUid = result.account.uid
|
|
635
|
+
saveSettings(config)
|
|
636
|
+
}
|
|
637
|
+
return json({ ok: true, ...result, settings: publicSettings(config), status: status() })
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
route(LOGOUT_ROUTE, ['POST'], async () => {
|
|
641
|
+
config.cookie = ''
|
|
642
|
+
config.accountName = ''
|
|
643
|
+
config.accountUid = ''
|
|
644
|
+
saveSettings(config)
|
|
645
|
+
return json({ ok: true, settings: publicSettings(config), status: status() })
|
|
646
|
+
})
|
|
647
|
+
|
|
364
648
|
route(RUN_ROUTE, ['POST'], async () => {
|
|
365
649
|
const result = await run('manual', true)
|
|
366
650
|
return json({ ok: true, result, status: status() })
|
|
367
651
|
})
|
|
652
|
+
|
|
653
|
+
route(CHECK_NOW_ROUTE, ['POST'], async () => {
|
|
654
|
+
const result = await checkNow()
|
|
655
|
+
return json({ ok: true, result, status: status() })
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
route(REPLY_NOW_ROUTE, ['POST'], async (request) => {
|
|
659
|
+
const body = await readJsonBody(request)
|
|
660
|
+
const result = await replyNow(body?.cid)
|
|
661
|
+
return json({ ok: true, result, status: status() })
|
|
662
|
+
})
|
|
368
663
|
})
|
|
369
664
|
|
|
370
665
|
log(
|
package/lib/judge.js
CHANGED
|
@@ -123,7 +123,8 @@ function finalize(parsed, input, fallbackText) {
|
|
|
123
123
|
if (input.forceComment && comment) action = 'comment'
|
|
124
124
|
if (action === 'comment' && !input.mayComment) action = 'remember'
|
|
125
125
|
if (action === 'comment' && !comment) action = 'remember'
|
|
126
|
-
|
|
126
|
+
let replyTo = input.mayReply ? String(parsed.replyTo || '0') : '0'
|
|
127
|
+
if (input.targetComment?.cid) replyTo = String(input.targetComment.cid)
|
|
127
128
|
return {
|
|
128
129
|
action,
|
|
129
130
|
comment: comment.slice(0, 300),
|
|
@@ -144,16 +145,24 @@ export async function judgePost(config, input) {
|
|
|
144
145
|
'- comment:值得在评论区说一句,才评论。',
|
|
145
146
|
input.mayComment ? '本次允许评论。' : '本次不允许评论,只能 skip 或 remember。',
|
|
146
147
|
'',
|
|
147
|
-
'评论要求:像 Foxy
|
|
148
|
+
'评论要求:像 Foxy 自己刷到后随口说的,中文第一人称;不设最少字数,能一句说清就一句,最多 80 字;不要客服腔,不要复述原文,不要写 @。',
|
|
148
149
|
'记忆要求:如果值得记,用真实事件写第一人称,例如“我在微博看到 xx 说……,觉得……”。不要把所有事件都写成“我刷到”。不超过 180 字。',
|
|
149
150
|
'',
|
|
150
|
-
input.
|
|
151
|
+
input.targetComment
|
|
151
152
|
? [
|
|
152
|
-
'
|
|
153
|
-
'
|
|
154
|
-
|
|
153
|
+
'你正在回复自己微博下的一条评论,不是在写新博文评论:',
|
|
154
|
+
`对方 ${input.targetComment.author?.name || input.targetComment.author?.uid || '网友'} 说:${firstLine(input.targetComment.text, 300)}`,
|
|
155
|
+
'如果是在正常交流,输出 action=comment 并直接回复这句话;不设最少字数,1–2 句,最多 80 字。',
|
|
156
|
+
'如果是广告、引战、辱骂、诈骗或无意义内容,输出 action=skip。',
|
|
157
|
+
'replyTo 填这条评论的 cid,不要回复其他人。',
|
|
155
158
|
].join('\n')
|
|
156
|
-
:
|
|
159
|
+
: input.mayReply && input.comments.length
|
|
160
|
+
? [
|
|
161
|
+
'回复规则:可以回复下面某条评论。replyTo 填要回复的 cid;只发一级评论时填 0。',
|
|
162
|
+
'不要回复自己,不要回复广告/引战/辱骂/诈骗。',
|
|
163
|
+
...commentLines(input.comments),
|
|
164
|
+
].join('\n')
|
|
165
|
+
: '本次不回复他人评论;replyTo 必须为 0。',
|
|
157
166
|
'',
|
|
158
167
|
'只输出一个 JSON 对象,不要 Markdown:',
|
|
159
168
|
'{"action":"skip|remember|comment","comment":"...","memory":"...","replyTo":0,"mood":"..."}',
|
package/lib/login.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weibo QR login.
|
|
3
|
+
*
|
|
4
|
+
* The QR endpoint returns JSONP, and the actual login cookies are spread over
|
|
5
|
+
* several redirects. Keep a tiny cookie jar instead of taking only the last
|
|
6
|
+
* Set-Cookie header.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const UA =
|
|
10
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126 Safari/537.36'
|
|
11
|
+
const MOBILE_UA =
|
|
12
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'
|
|
13
|
+
|
|
14
|
+
function parseJsonp(text) {
|
|
15
|
+
const raw = String(text || '')
|
|
16
|
+
const start = raw.indexOf('{')
|
|
17
|
+
const end = raw.lastIndexOf('}')
|
|
18
|
+
if (start < 0 || end < start) throw new Error('微博登录接口返回格式异常')
|
|
19
|
+
return JSON.parse(raw.slice(start, end + 1))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setCookies(response) {
|
|
23
|
+
if (typeof response.headers.getSetCookie === 'function') {
|
|
24
|
+
return response.headers.getSetCookie()
|
|
25
|
+
}
|
|
26
|
+
const raw = response.headers.get('set-cookie')
|
|
27
|
+
return raw ? [raw] : []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class CookieJar {
|
|
31
|
+
constructor(initial = '') {
|
|
32
|
+
this.values = new Map()
|
|
33
|
+
this.add(initial)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
add(raw) {
|
|
37
|
+
for (const part of String(raw || '').split(/;\s*(?=[^;=]+=)/)) {
|
|
38
|
+
const text = part.trim()
|
|
39
|
+
if (!text || text.includes('\n')) {
|
|
40
|
+
for (const line of text.split('\n')) this.addOne(line)
|
|
41
|
+
} else {
|
|
42
|
+
this.addOne(text)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
addOne(raw) {
|
|
48
|
+
const text = String(raw || '').split(';')[0].trim()
|
|
49
|
+
const index = text.indexOf('=')
|
|
50
|
+
if (index <= 0) return
|
|
51
|
+
const key = text.slice(0, index).trim()
|
|
52
|
+
const value = text.slice(index + 1).trim()
|
|
53
|
+
if (key && value) this.values.set(key, value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
absorb(response) {
|
|
57
|
+
for (const cookie of setCookies(response)) this.addOne(cookie)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
header() {
|
|
61
|
+
return [...this.values.entries()].map(([key, value]) => `${key}=${value}`).join('; ')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function request(url, { jar, mobile = false, ...options } = {}) {
|
|
66
|
+
const headers = {
|
|
67
|
+
accept: 'application/json, text/plain, */*',
|
|
68
|
+
'user-agent': mobile ? MOBILE_UA : UA,
|
|
69
|
+
referer: mobile ? 'https://m.weibo.cn/' : 'https://weibo.com/',
|
|
70
|
+
...(options.headers || {}),
|
|
71
|
+
}
|
|
72
|
+
if (jar?.header()) headers.cookie = jar.header()
|
|
73
|
+
const response = await fetch(url, { ...options, headers, redirect: 'manual' })
|
|
74
|
+
jar?.absorb(response)
|
|
75
|
+
return response
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function follow(response, jar, mobile = false, depth = 0) {
|
|
79
|
+
if (depth > 5) return response
|
|
80
|
+
const location = response.headers.get('location')
|
|
81
|
+
if (!location) return response
|
|
82
|
+
let next
|
|
83
|
+
try {
|
|
84
|
+
next = new URL(location, response.url).toString()
|
|
85
|
+
} catch {
|
|
86
|
+
return response
|
|
87
|
+
}
|
|
88
|
+
const redirected = await request(next, { jar, mobile })
|
|
89
|
+
return follow(redirected, jar, mobile, depth + 1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function loginStart() {
|
|
93
|
+
const stamp = Date.now()
|
|
94
|
+
const url =
|
|
95
|
+
`https://login.sina.com.cn/sso/qrcode/image?entry=weibo&size=180&callback=STK_${stamp}`
|
|
96
|
+
const jar = new CookieJar()
|
|
97
|
+
const response = await request(url, { jar })
|
|
98
|
+
if (!response.ok) throw new Error(`获取微博二维码失败:HTTP ${response.status}`)
|
|
99
|
+
const data = parseJsonp(await response.text())
|
|
100
|
+
const row = data?.data || {}
|
|
101
|
+
if (!row.qrid || !row.image) throw new Error(data?.msg || '微博没有返回二维码')
|
|
102
|
+
const image = String(row.image).startsWith('//') ? `https:${row.image}` : String(row.image)
|
|
103
|
+
// Returning a data URL keeps the QR usable even if the DSH page CSP blocks
|
|
104
|
+
// hotlinked images; fall back to the remote URL if the image cannot be read.
|
|
105
|
+
let qr = image
|
|
106
|
+
try {
|
|
107
|
+
const imageResponse = await fetch(image, {
|
|
108
|
+
headers: { 'user-agent': UA, referer: 'https://weibo.com/' },
|
|
109
|
+
})
|
|
110
|
+
if (imageResponse.ok) {
|
|
111
|
+
const type = imageResponse.headers.get('content-type') || 'image/png'
|
|
112
|
+
const bytes = Buffer.from(await imageResponse.arrayBuffer())
|
|
113
|
+
if (bytes.length && type.startsWith('image/')) {
|
|
114
|
+
qr = `data:${type};base64,${bytes.toString('base64')}`
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// remote URL fallback
|
|
119
|
+
}
|
|
120
|
+
return { qrid: String(row.qrid), qr, retcode: data.retcode }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function loginPoll(qrid) {
|
|
124
|
+
const key = String(qrid || '').trim()
|
|
125
|
+
if (!key) throw new Error('缺少微博二维码 qrid')
|
|
126
|
+
const stamp = Date.now()
|
|
127
|
+
const url =
|
|
128
|
+
`https://login.sina.com.cn/sso/qrcode/check?entry=weibo&qrid=${encodeURIComponent(key)}` +
|
|
129
|
+
`&callback=STK_${stamp}`
|
|
130
|
+
const jar = new CookieJar()
|
|
131
|
+
const response = await request(url, { jar })
|
|
132
|
+
const data = parseJsonp(await response.text())
|
|
133
|
+
const retcode = Number(data.retcode)
|
|
134
|
+
if (retcode === 50114001) return { state: 'waiting', retcode }
|
|
135
|
+
if (retcode === 50114002) return { state: 'scanned', retcode }
|
|
136
|
+
if (retcode === 50114004) return { state: 'expired', retcode }
|
|
137
|
+
const alt = String(data?.data?.alt || data?.data?.url || '').trim()
|
|
138
|
+
if (!alt) return { state: 'waiting', retcode, raw: data }
|
|
139
|
+
|
|
140
|
+
const loginUrl =
|
|
141
|
+
`https://login.sina.com.cn/sso/login.php?entry=weibo&returntype=TEXT&crossdomain=1` +
|
|
142
|
+
`&cdult=3&domain=weibo.com&alt=${encodeURIComponent(alt)}&savestate=30`
|
|
143
|
+
let login = await request(loginUrl, { jar })
|
|
144
|
+
login = await follow(login, jar, false)
|
|
145
|
+
await request('https://weibo.com/', { jar })
|
|
146
|
+
const mobile = await request('https://m.weibo.cn/', { jar, mobile: true })
|
|
147
|
+
await follow(mobile, jar, true, 0).catch(() => {})
|
|
148
|
+
const cookie = jar.header()
|
|
149
|
+
if (!cookie.includes('SUB=') && !cookie.includes('SUBP=')) {
|
|
150
|
+
throw new Error('微博登录未返回 SUB/SUBP Cookie,请重试')
|
|
151
|
+
}
|
|
152
|
+
return { state: 'ok', cookie, account: await fetchAccount(cookie) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function fetchAccount(cookie) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch('https://m.weibo.cn/api/config', {
|
|
158
|
+
headers: {
|
|
159
|
+
accept: 'application/json, text/plain, */*',
|
|
160
|
+
'user-agent': MOBILE_UA,
|
|
161
|
+
referer: 'https://m.weibo.cn/',
|
|
162
|
+
cookie,
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
const data = await response.json()
|
|
166
|
+
return {
|
|
167
|
+
uid: String(data?.data?.uid || ''),
|
|
168
|
+
screenName: String(data?.data?.screen_name || ''),
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return { uid: '', screenName: '' }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function maskCookie(cookie) {
|
|
176
|
+
return String(cookie || '')
|
|
177
|
+
.split(';')
|
|
178
|
+
.map((part) => {
|
|
179
|
+
const index = part.indexOf('=')
|
|
180
|
+
if (index < 0) return part.trim()
|
|
181
|
+
return `${part.slice(0, index).trim()}=***`
|
|
182
|
+
})
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join('; ')
|
|
185
|
+
}
|
package/lib/settings.js
CHANGED
|
@@ -5,12 +5,16 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
export const DEFAULTS = {
|
|
6
6
|
enabled: false,
|
|
7
7
|
cookie: '',
|
|
8
|
+
selfUid: '',
|
|
8
9
|
targetUids: [],
|
|
10
|
+
replyOwnPosts: true,
|
|
11
|
+
replyMode: 'off', // off | auto
|
|
12
|
+
scheduleTimes: ['20:00'],
|
|
9
13
|
intervalMinutes: 60,
|
|
10
14
|
maxPostsPerRun: 5,
|
|
11
15
|
commentMode: 'off',
|
|
12
16
|
maxCommentsPerDay: 1,
|
|
13
|
-
replyOthers:
|
|
17
|
+
replyOthers: true,
|
|
14
18
|
enableMemory: true,
|
|
15
19
|
memoryPresetId: 'foxy3(Character)',
|
|
16
20
|
llmBaseUrl: 'https://api.deepseek.com',
|
|
@@ -22,7 +26,11 @@ export const DEFAULTS = {
|
|
|
22
26
|
|
|
23
27
|
export const SETTING_KEYS = [
|
|
24
28
|
'enabled',
|
|
29
|
+
'selfUid',
|
|
25
30
|
'targetUids',
|
|
31
|
+
'replyOwnPosts',
|
|
32
|
+
'replyMode',
|
|
33
|
+
'scheduleTimes',
|
|
26
34
|
'intervalMinutes',
|
|
27
35
|
'maxPostsPerRun',
|
|
28
36
|
'commentMode',
|
|
@@ -37,13 +45,16 @@ export const SETTING_KEYS = [
|
|
|
37
45
|
]
|
|
38
46
|
|
|
39
47
|
const TEXT_KEYS = new Set([
|
|
48
|
+
'selfUid',
|
|
40
49
|
'commentMode',
|
|
50
|
+
'replyMode',
|
|
41
51
|
'memoryPresetId',
|
|
42
52
|
'llmBaseUrl',
|
|
43
53
|
'llmModel',
|
|
44
54
|
])
|
|
45
55
|
const BOOLEAN_KEYS = new Set([
|
|
46
56
|
'enabled',
|
|
57
|
+
'replyOwnPosts',
|
|
47
58
|
'replyOthers',
|
|
48
59
|
'enableMemory',
|
|
49
60
|
'runOnStart',
|
|
@@ -70,6 +81,18 @@ export function normalizeUids(value) {
|
|
|
70
81
|
return out
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
export function normalizeScheduleTimes(value) {
|
|
85
|
+
const raw = Array.isArray(value) ? value : String(value || '').split(/[\s,,]+/)
|
|
86
|
+
const out = []
|
|
87
|
+
for (const item of raw) {
|
|
88
|
+
const text = String(item || '').trim()
|
|
89
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(text) || out.includes(text)) continue
|
|
90
|
+
out.push(text)
|
|
91
|
+
if (out.length >= 6) break
|
|
92
|
+
}
|
|
93
|
+
return out.sort()
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
function clampNumber(key, value, fallback) {
|
|
74
97
|
const n = Number(value)
|
|
75
98
|
if (!Number.isFinite(n)) return fallback
|
|
@@ -82,13 +105,14 @@ function clampNumber(key, value, fallback) {
|
|
|
82
105
|
export function normalizePatch(current = {}, patch = {}) {
|
|
83
106
|
const next = { ...DEFAULTS, ...current }
|
|
84
107
|
for (const key of SETTING_KEYS) {
|
|
85
|
-
if (patch[key] === undefined || key === 'targetUids') continue
|
|
108
|
+
if (patch[key] === undefined || key === 'targetUids' || key === 'scheduleTimes') continue
|
|
86
109
|
if (BOOLEAN_KEYS.has(key)) next[key] = patch[key] === true
|
|
87
110
|
else if (key === 'commentMode') next[key] = patch[key] === 'auto' ? 'auto' : 'off'
|
|
88
111
|
else if (TEXT_KEYS.has(key)) next[key] = String(patch[key] ?? '').trim()
|
|
89
112
|
else next[key] = clampNumber(key, patch[key], next[key])
|
|
90
113
|
}
|
|
91
114
|
if (patch.targetUids !== undefined) next.targetUids = normalizeUids(patch.targetUids)
|
|
115
|
+
if (patch.scheduleTimes !== undefined) next.scheduleTimes = normalizeScheduleTimes(patch.scheduleTimes)
|
|
92
116
|
if (patch.cookie !== undefined) next.cookie = String(patch.cookie || '')
|
|
93
117
|
if (patch.clearCookie === true) next.cookie = ''
|
|
94
118
|
if (typeof patch.accountName === 'string') next.accountName = patch.accountName.trim()
|
|
@@ -98,9 +122,12 @@ export function normalizePatch(current = {}, patch = {}) {
|
|
|
98
122
|
}
|
|
99
123
|
if (patch.clearLlmApiKey === true) next.llmApiKey = ''
|
|
100
124
|
next.enabled = next.enabled === true
|
|
101
|
-
next.
|
|
125
|
+
next.replyOwnPosts = next.replyOwnPosts !== false
|
|
126
|
+
next.replyOthers = next.replyOthers !== false
|
|
102
127
|
next.enableMemory = next.enableMemory !== false
|
|
128
|
+
next.replyMode = next.replyMode === 'auto' ? 'auto' : 'off'
|
|
103
129
|
next.targetUids = normalizeUids(next.targetUids)
|
|
130
|
+
next.scheduleTimes = normalizeScheduleTimes(next.scheduleTimes)
|
|
104
131
|
return next
|
|
105
132
|
}
|
|
106
133
|
|
package/package.json
CHANGED