dsh-foxy-weibo 0.1.0 → 0.2.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 +28 -13
- package/cordis.patch.yml +6 -1
- package/lib/client.js +114 -24
- package/lib/index.js +240 -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. 旧模式需要浏览他人博文时,再关闭「主模式」并配置关注 UID;
|
|
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,11 @@ 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} 条`,
|
|
149
226
|
)
|
|
150
227
|
} catch (e) { setError(e.message) } finally { setBusy('') }
|
|
151
228
|
}
|
|
@@ -168,23 +245,26 @@ window.__ModuleLoader__.load({
|
|
|
168
245
|
),
|
|
169
246
|
|
|
170
247
|
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) })),
|
|
248
|
+
h(SectionHead, { title: '账号与登录', description: '扫码登录后即可回复评论;Cookie 输入保留为备用方式。' }),
|
|
174
249
|
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 }),
|
|
250
|
+
h(Field, { label: '当前账号', hint: '扫码成功后自动显示' },
|
|
251
|
+
h('input', { value: settings.accountName ? `${settings.accountName}(${settings.accountUid || '?'})` : settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
|
|
179
252
|
),
|
|
253
|
+
h(Field, { label: '自己的微博 UID', hint: '主功能会检查这个账号的新博文评论' },
|
|
254
|
+
h('input', { value: settings.selfUid || '', placeholder: '例如 1234567890', onChange: (e) => setField('selfUid', e.target.value) })),
|
|
180
255
|
),
|
|
181
256
|
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
|
-
} }, '退出登录'),
|
|
257
|
+
h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: startLogin }, busy === 'login' ? '获取二维码…' : qr ? '刷新二维码' : '扫码登录'),
|
|
258
|
+
h('button', { className: 'fw-btn', disabled: !!busy || !settings.cookieSet, onClick: logout }, busy === 'logout' ? '退出中…' : '退出登录'),
|
|
187
259
|
),
|
|
260
|
+
qr
|
|
261
|
+
? h('div', { className: 'fw-qr' },
|
|
262
|
+
h('img', { src: qr, alt: '微博登录二维码', width: 180, height: 180 }),
|
|
263
|
+
h('p', null, qrState === 'scanned' ? '已扫码,请在手机上确认登录' : qrState === 'expired' ? '二维码已过期,请刷新' : '请用微博 App 扫码'),
|
|
264
|
+
)
|
|
265
|
+
: null,
|
|
266
|
+
h(Field, { label: '微博 Cookie(备用)', hint: settings.cookieSet ? '已保存;留空表示不修改' : '从浏览器复制 SUB、SUBP、XSRF-TOKEN 等 Cookie' },
|
|
267
|
+
h('textarea', { value: cookieDraft, placeholder: settings.cookieSet ? '已保存,留空不修改' : 'SUB=...; SUBP=...; XSRF-TOKEN=...', onChange: (e) => setCookieDraft(e.target.value) })),
|
|
188
268
|
),
|
|
189
269
|
|
|
190
270
|
h('section', { className: 'fw-card' },
|
|
@@ -200,22 +280,32 @@ window.__ModuleLoader__.load({
|
|
|
200
280
|
),
|
|
201
281
|
|
|
202
282
|
h('section', { className: 'fw-card' },
|
|
203
|
-
h(SectionHead, { title: '
|
|
204
|
-
h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '
|
|
283
|
+
h(SectionHead, { title: '回复自己的博文', description: '主功能:每天按设定时间检查自己的新博文,只回复未回复的他人评论。' }),
|
|
284
|
+
h(Toggle, { checked: settings.enabled, onChange: (v) => setField('enabled', v), label: '启用自动检查', hint: '关闭后定时任务不运行,仍可使用「立即运行」。' }),
|
|
285
|
+
h(Toggle, { checked: settings.replyOwnPosts, onChange: (v) => setField('replyOwnPosts', v), label: '主模式:只看自己的博文评论', hint: '关闭后回到旧模式,浏览关注账号的时间线并评论博文。' }),
|
|
205
286
|
h('div', { className: 'fw-grid' },
|
|
206
|
-
h(Field, { label: '
|
|
207
|
-
h('input', {
|
|
208
|
-
h(Field, { label: '
|
|
287
|
+
h(Field, { label: '每日检查时间', hint: 'HH:MM,多个用逗号分隔,最多 6 个,例如 08:30, 20:00' },
|
|
288
|
+
h('input', { value: scheduleText, placeholder: '20:00', onChange: (e) => setScheduleText(e.target.value) })),
|
|
289
|
+
h(Field, { label: '回复模式', hint: '自动才会发布回复;建议先仅记录' },
|
|
290
|
+
h('select', { value: settings.replyMode, onChange: (e) => setField('replyMode', e.target.value) },
|
|
291
|
+
h('option', { value: 'off' }, '仅检查,不回复'),
|
|
292
|
+
h('option', { value: 'auto' }, '自动回复'),
|
|
293
|
+
)),
|
|
294
|
+
h(Field, { label: '每轮检查博文数', hint: '范围 1–20' },
|
|
209
295
|
h('input', { type: 'number', min: 1, max: 20, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
|
|
210
|
-
h(Field, { label: '
|
|
296
|
+
h(Field, { label: '每日回复上限', hint: '0–10,同时限制评论' },
|
|
297
|
+
h('input', { type: 'number', min: 0, max: 10, value: settings.maxCommentsPerDay, onChange: (e) => setField('maxCommentsPerDay', e.target.value) })),
|
|
298
|
+
),
|
|
299
|
+
h('div', { className: 'fw-grid' },
|
|
300
|
+
h(Field, { label: '时间线轮询(旧模式备用)', hint: '关闭主模式时使用,分钟' },
|
|
301
|
+
h('input', { type: 'number', min: 5, max: 1440, value: settings.intervalMinutes, onChange: (e) => setField('intervalMinutes', e.target.value) })),
|
|
302
|
+
h(Field, { label: '旧模式评论模式', hint: '浏览他人博文时是否发一级评论' },
|
|
211
303
|
h('select', { value: settings.commentMode, onChange: (e) => setField('commentMode', e.target.value) },
|
|
212
304
|
h('option', { value: 'off' }, '仅记录'),
|
|
213
305
|
h('option', { value: 'auto' }, '自动评论'),
|
|
214
306
|
)),
|
|
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
307
|
),
|
|
218
|
-
h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '
|
|
308
|
+
h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '旧模式可从评论区选择一条评论回复。' }),
|
|
219
309
|
h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '写入 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
|
|
220
310
|
h(Toggle, { checked: settings.runOnStart, onChange: (v) => setField('runOnStart', v), label: '启动 DSH 后自动运行', hint: '启动约 20 秒后执行一轮。' }),
|
|
221
311
|
h(Toggle, { checked: settings.debug, onChange: (v) => setField('debug', v), label: '记录调试日志', hint: '保存模型输出与记忆写入结果,便于排查。' }),
|
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,9 @@ 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 LOGIN_START_ROUTE = '/api/dsh-foxy-weibo/login/start'
|
|
15
|
+
export const LOGIN_POLL_ROUTE = '/api/dsh-foxy-weibo/login/poll'
|
|
16
|
+
export const LOGOUT_ROUTE = '/api/dsh-foxy-weibo/logout'
|
|
13
17
|
|
|
14
18
|
const ACTION_LABELS = { skip: '跳过', remember: '记录', comment: '评论', error: '失败' }
|
|
15
19
|
|
|
@@ -22,6 +26,8 @@ function loadState() {
|
|
|
22
26
|
const parsed = JSON.parse(readFileSync(statePath(), 'utf8'))
|
|
23
27
|
return {
|
|
24
28
|
seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
|
|
29
|
+
commentSeen:
|
|
30
|
+
parsed.commentSeen && typeof parsed.commentSeen === 'object' ? parsed.commentSeen : {},
|
|
25
31
|
days: parsed.days && typeof parsed.days === 'object' ? parsed.days : {},
|
|
26
32
|
history: Array.isArray(parsed.history) ? parsed.history.slice(0, 50) : [],
|
|
27
33
|
debug: Array.isArray(parsed.debug) ? parsed.debug.slice(0, 80) : [],
|
|
@@ -29,7 +35,7 @@ function loadState() {
|
|
|
29
35
|
lastResult: parsed.lastResult || null,
|
|
30
36
|
}
|
|
31
37
|
} catch {
|
|
32
|
-
return { seen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
|
|
38
|
+
return { seen: {}, commentSeen: {}, days: {}, history: [], debug: [], lastRun: 0, lastResult: null }
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -120,6 +126,176 @@ async function remember(ctx, config, state, post, decision, extraText = '') {
|
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
async function rememberReply(ctx, config, state, post, comment, replyText) {
|
|
130
|
+
if (!config.enableMemory) return { ok: false, reason: 'disabled' }
|
|
131
|
+
const provider = memoryProvider(ctx)
|
|
132
|
+
if (!provider?.record) return { ok: false, reason: 'no-provider' }
|
|
133
|
+
const person = comment.author?.name || comment.author?.uid || '一位网友'
|
|
134
|
+
const text = `我在微博回复了 ${person} 在自己博文「${firstLine(post.text, 60)}」下的评论:${replyText}`
|
|
135
|
+
try {
|
|
136
|
+
await provider.record({
|
|
137
|
+
role: 'assistant',
|
|
138
|
+
text,
|
|
139
|
+
sessionId: `weibo-comment:${post.id}:${comment.cid}`,
|
|
140
|
+
presetId: config.memoryPresetId || 'foxy3(Character)',
|
|
141
|
+
platform: 'weibo',
|
|
142
|
+
sourceLabel: '微博评论回复',
|
|
143
|
+
eventType: 'weibo_reply',
|
|
144
|
+
channelType: 'weibo_comment',
|
|
145
|
+
channelId: String(comment.cid),
|
|
146
|
+
title: firstLine(post.text, 120),
|
|
147
|
+
url: post.url,
|
|
148
|
+
scene: 'weibo',
|
|
149
|
+
})
|
|
150
|
+
debugLog(state, config, { kind: 'memory', post: post.id, memory: 'written', text: firstLine(text, 300) })
|
|
151
|
+
return { ok: true, reason: 'written', text }
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const reason = error?.message || String(error)
|
|
154
|
+
debugLog(state, config, { kind: 'memory', post: post.id, memory: 'error', reason })
|
|
155
|
+
return { ok: false, reason }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function scanOwnComments(ctx, config, state, log, force = false) {
|
|
160
|
+
if (!config.enabled && !force) return { skipped: 'disabled' }
|
|
161
|
+
const canReply = config.replyMode === 'auto'
|
|
162
|
+
const selfUid = String(config.selfUid || config.accountUid || '').trim()
|
|
163
|
+
if (!selfUid) return { skipped: 'no-self' }
|
|
164
|
+
const maxPosts = Math.max(1, Math.min(20, Number(config.maxPostsPerRun) || 5))
|
|
165
|
+
const maxReplies = Math.max(1, Math.min(8, Number(config.maxCommentsPerDay) || 1))
|
|
166
|
+
const result = {
|
|
167
|
+
mode: 'own-comments',
|
|
168
|
+
checked: 0,
|
|
169
|
+
replied: 0,
|
|
170
|
+
previewed: 0,
|
|
171
|
+
skipped: 0,
|
|
172
|
+
errors: 0,
|
|
173
|
+
items: [],
|
|
174
|
+
}
|
|
175
|
+
let posts = []
|
|
176
|
+
try {
|
|
177
|
+
posts = await fetchTimeline(selfUid, config)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return { ...result, errors: 1, error: error?.message || String(error) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const post of posts.slice(0, maxPosts)) {
|
|
183
|
+
if (!post.id) continue
|
|
184
|
+
let comments = []
|
|
185
|
+
try {
|
|
186
|
+
comments = await fetchComments(post.mid, config, 30)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
result.errors += 1
|
|
189
|
+
log(`[foxy-weibo] 读取评论失败 ${post.id}:${error?.message || error}`)
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
for (const comment of comments) {
|
|
193
|
+
if (canReply && result.replied >= maxReplies) break
|
|
194
|
+
if (!comment.cid || state.commentSeen[comment.cid]) continue
|
|
195
|
+
if (String(comment.author?.uid || '') === selfUid) {
|
|
196
|
+
state.commentSeen[comment.cid] = { at: Date.now(), status: 'self' }
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const limit = Math.max(0, Number(config.maxCommentsPerDay) || 0)
|
|
200
|
+
if (canReply && commentCount(state) >= limit) break
|
|
201
|
+
result.checked += 1
|
|
202
|
+
let entry
|
|
203
|
+
try {
|
|
204
|
+
const decision = await judgePost(config, {
|
|
205
|
+
summary: postSummary(post),
|
|
206
|
+
comments,
|
|
207
|
+
commentsText: commentsTextOf(comments),
|
|
208
|
+
targetComment: {
|
|
209
|
+
cid: String(comment.cid),
|
|
210
|
+
text: comment.text,
|
|
211
|
+
author: comment.author,
|
|
212
|
+
},
|
|
213
|
+
mayComment: true,
|
|
214
|
+
mayReply: true,
|
|
215
|
+
})
|
|
216
|
+
debugLog(state, config, {
|
|
217
|
+
kind: 'judge-own-comment',
|
|
218
|
+
post: post.id,
|
|
219
|
+
cid: String(comment.cid),
|
|
220
|
+
action: decision.action,
|
|
221
|
+
raw: String(decision.raw || '').slice(0, 500),
|
|
222
|
+
})
|
|
223
|
+
if (decision.action !== 'comment' || !decision.comment) {
|
|
224
|
+
result.skipped += 1
|
|
225
|
+
state.commentSeen[comment.cid] = { at: Date.now(), status: 'skipped' }
|
|
226
|
+
entry = {
|
|
227
|
+
at: Date.now(),
|
|
228
|
+
post: post.id,
|
|
229
|
+
cid: String(comment.cid),
|
|
230
|
+
author: comment.author?.name || comment.author?.uid,
|
|
231
|
+
title: firstLine(post.text, 100),
|
|
232
|
+
action: 'skip',
|
|
233
|
+
reason: decision.mood || '模型判断不需要回复',
|
|
234
|
+
memory: 'skipped',
|
|
235
|
+
}
|
|
236
|
+
} else if (!canReply) {
|
|
237
|
+
result.previewed += 1
|
|
238
|
+
entry = {
|
|
239
|
+
at: Date.now(),
|
|
240
|
+
post: post.id,
|
|
241
|
+
cid: String(comment.cid),
|
|
242
|
+
author: comment.author?.name || comment.author?.uid,
|
|
243
|
+
title: firstLine(post.text, 100),
|
|
244
|
+
action: 'preview',
|
|
245
|
+
comment: decision.comment,
|
|
246
|
+
reason: '回复模式为仅检查,未发布',
|
|
247
|
+
memory: 'skipped',
|
|
248
|
+
}
|
|
249
|
+
log(`[foxy-weibo] 仅检查 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
|
|
250
|
+
} else {
|
|
251
|
+
await postComment({
|
|
252
|
+
mid: post.mid,
|
|
253
|
+
text: decision.comment,
|
|
254
|
+
cid: String(comment.cid),
|
|
255
|
+
config,
|
|
256
|
+
})
|
|
257
|
+
bumpComments(state)
|
|
258
|
+
result.replied += 1
|
|
259
|
+
state.commentSeen[comment.cid] = { at: Date.now(), status: 'replied' }
|
|
260
|
+
const memory = await rememberReply(ctx, config, state, post, comment, decision.comment)
|
|
261
|
+
entry = {
|
|
262
|
+
at: Date.now(),
|
|
263
|
+
post: post.id,
|
|
264
|
+
cid: String(comment.cid),
|
|
265
|
+
author: comment.author?.name || comment.author?.uid,
|
|
266
|
+
title: firstLine(post.text, 100),
|
|
267
|
+
action: 'reply',
|
|
268
|
+
comment: decision.comment,
|
|
269
|
+
reason: firstLine(decision.comment, 140),
|
|
270
|
+
memory: memory.ok ? 'written' : memory.reason,
|
|
271
|
+
}
|
|
272
|
+
log(`[foxy-weibo] 回复 ${post.id} ${entry.author} -> ${firstLine(decision.comment, 80)}`)
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
result.errors += 1
|
|
276
|
+
entry = {
|
|
277
|
+
at: Date.now(),
|
|
278
|
+
post: post.id,
|
|
279
|
+
cid: String(comment.cid),
|
|
280
|
+
author: comment.author?.name || comment.author?.uid,
|
|
281
|
+
title: firstLine(post.text, 100),
|
|
282
|
+
action: 'error',
|
|
283
|
+
reason: '回复评论时出错',
|
|
284
|
+
error: error?.message || String(error),
|
|
285
|
+
}
|
|
286
|
+
log(`[foxy-weibo] 回复评论 ${comment.cid} 失败:${error?.message || error}`)
|
|
287
|
+
}
|
|
288
|
+
if (entry) {
|
|
289
|
+
pushHistory(state, entry)
|
|
290
|
+
result.items.push(entry)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
state.lastRun = Date.now()
|
|
295
|
+
state.lastResult = result
|
|
296
|
+
return result
|
|
297
|
+
}
|
|
298
|
+
|
|
123
299
|
async function scanOnce(ctx, config, state, log, force = false) {
|
|
124
300
|
if (!config.enabled && !force) return { skipped: 'disabled' }
|
|
125
301
|
if (!config.targetUids.length) return { skipped: 'no-targets' }
|
|
@@ -282,7 +458,10 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
282
458
|
running = true
|
|
283
459
|
try {
|
|
284
460
|
log(`[foxy-weibo] 开始运行(${reason})`)
|
|
285
|
-
const result =
|
|
461
|
+
const result =
|
|
462
|
+
config.replyOwnPosts === false
|
|
463
|
+
? await scanOnce(ctx, config, state, log, force)
|
|
464
|
+
: await scanOwnComments(ctx, config, state, log, force)
|
|
286
465
|
saveState(state)
|
|
287
466
|
log(`[foxy-weibo] 运行结束 ${JSON.stringify(result)}`)
|
|
288
467
|
return result
|
|
@@ -298,14 +477,22 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
298
477
|
const status = () => ({
|
|
299
478
|
enabled: config.enabled === true,
|
|
300
479
|
running,
|
|
480
|
+
mode: config.replyOwnPosts === false ? 'timeline' : 'own-comments',
|
|
481
|
+
selfUid: config.selfUid || config.accountUid || '',
|
|
482
|
+
scheduleTimes: config.scheduleTimes,
|
|
483
|
+
nextRunAt,
|
|
484
|
+
replyMode: config.replyMode,
|
|
301
485
|
lastRun: state.lastRun,
|
|
302
486
|
lastResult: state.lastResult,
|
|
303
487
|
recent: state.history.slice(0, 12),
|
|
304
488
|
debugEnabled: config.debug === true,
|
|
305
489
|
debug: state.debug.slice(0, 40),
|
|
306
490
|
seen: Object.keys(state.seen).length,
|
|
491
|
+
commentsSeen: Object.keys(state.commentSeen || {}).length,
|
|
307
492
|
commentsToday: commentCount(state),
|
|
308
493
|
cookieSet: Boolean(config.cookie),
|
|
494
|
+
accountName: config.accountName || '',
|
|
495
|
+
accountUid: config.accountUid || '',
|
|
309
496
|
apiKeySet: Boolean(config.llmApiKey),
|
|
310
497
|
targetCount: config.targetUids.length,
|
|
311
498
|
})
|
|
@@ -315,14 +502,37 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
315
502
|
foxy?.registerCapability?.('weibo', async () => capability)
|
|
316
503
|
ctx.provide?.('foxyWeibo', capability)
|
|
317
504
|
|
|
505
|
+
let nextRunAt = 0
|
|
506
|
+
const nextDelay = () => {
|
|
507
|
+
const times = Array.isArray(config.scheduleTimes) ? config.scheduleTimes : []
|
|
508
|
+
if (!times.length) return Math.max(5, Number(config.intervalMinutes) || 60) * 60 * 1000
|
|
509
|
+
const now = new Date()
|
|
510
|
+
let best = Infinity
|
|
511
|
+
for (const text of times) {
|
|
512
|
+
const [hour, minute] = String(text).split(':').map(Number)
|
|
513
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute)) continue
|
|
514
|
+
for (let day = 0; day <= 1; day += 1) {
|
|
515
|
+
const at = new Date(now)
|
|
516
|
+
at.setDate(now.getDate() + day)
|
|
517
|
+
at.setHours(hour, minute, 0, 0)
|
|
518
|
+
const delay = at.getTime() - now.getTime()
|
|
519
|
+
if (delay > 0 && delay < best) best = delay
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return Number.isFinite(best) ? best : 60 * 60 * 1000
|
|
523
|
+
}
|
|
318
524
|
const restartTimer = () => {
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
525
|
+
clearTimeout(timer)
|
|
526
|
+
const delay = nextDelay()
|
|
527
|
+
nextRunAt = Date.now() + delay
|
|
528
|
+
timer = setTimeout(async () => {
|
|
529
|
+
await run('schedule').catch(() => {})
|
|
530
|
+
restartTimer()
|
|
531
|
+
}, delay)
|
|
322
532
|
timer.unref?.()
|
|
323
533
|
}
|
|
324
534
|
restartTimer()
|
|
325
|
-
ctx.effect?.(() => () =>
|
|
535
|
+
ctx.effect?.(() => () => clearTimeout(timer), 'foxy-weibo: scan timer')
|
|
326
536
|
|
|
327
537
|
if (config.runOnStart) {
|
|
328
538
|
startTimer = setTimeout(() => run('start').catch(() => {}), 20_000)
|
|
@@ -361,6 +571,29 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
361
571
|
}
|
|
362
572
|
return json({ ok: true, settings: publicSettings(config), status: status() })
|
|
363
573
|
})
|
|
574
|
+
route(LOGIN_START_ROUTE, ['POST'], async () => json({ ok: true, ...(await loginStart()) }))
|
|
575
|
+
|
|
576
|
+
route(LOGIN_POLL_ROUTE, ['POST'], async (request) => {
|
|
577
|
+
const { qrid } = await readJsonBody(request)
|
|
578
|
+
const result = await loginPoll(qrid)
|
|
579
|
+
if (result.state === 'ok' && result.cookie) {
|
|
580
|
+
config.cookie = result.cookie
|
|
581
|
+
config.accountName = result.account?.screenName || ''
|
|
582
|
+
config.accountUid = result.account?.uid || ''
|
|
583
|
+
if (!config.selfUid && result.account?.uid) config.selfUid = result.account.uid
|
|
584
|
+
saveSettings(config)
|
|
585
|
+
}
|
|
586
|
+
return json({ ok: true, ...result, settings: publicSettings(config), status: status() })
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
route(LOGOUT_ROUTE, ['POST'], async () => {
|
|
590
|
+
config.cookie = ''
|
|
591
|
+
config.accountName = ''
|
|
592
|
+
config.accountUid = ''
|
|
593
|
+
saveSettings(config)
|
|
594
|
+
return json({ ok: true, settings: publicSettings(config), status: status() })
|
|
595
|
+
})
|
|
596
|
+
|
|
364
597
|
route(RUN_ROUTE, ['POST'], async () => {
|
|
365
598
|
const result = await run('manual', true)
|
|
366
599
|
return json({ ok: true, result, status: status() })
|
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