dsh-foxy-weibo 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +110 -2
- package/cordis.patch.yml +29 -0
- package/lib/client.js +368 -0
- package/lib/index.js +607 -0
- package/lib/judge.js +191 -0
- package/lib/login.js +185 -0
- package/lib/settings.js +162 -0
- package/lib/weibo.js +266 -0
- package/package.json +48 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kun-Fox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-foxy-weibo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Foxy 的微博子插件:按 foxy3 预设关注微博账号、阅读博文、判断是否评论或记录,并通过 `foxyMemory` 写入微博事件。
|
|
4
|
+
|
|
5
|
+
> 这是一个 **DSH 插件**,不是 Koishi 插件。它依赖 `dsh-foxy`(人设)和可选的 `dsh-foxy-memory`(记忆)。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
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、模型、每日时间、回复模式、调试日志与立即运行。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
dsh plugin --profile desktop add file:D:\Documents\deepseek-harness\default-workspace\dsh-foxy-weibo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
安装后完整重启 DSH。左侧栏会出现 **Foxy-weibo**,设置页也会出现同名条目。
|
|
24
|
+
|
|
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
|
+
|
|
38
|
+
### 微博 Cookie
|
|
39
|
+
|
|
40
|
+
读取公开时间线在部分网络环境会触发微博风控(HTTP 432),因此建议登录:
|
|
41
|
+
|
|
42
|
+
- `SUB`
|
|
43
|
+
- `SUBP`
|
|
44
|
+
- `XSRF-TOKEN`
|
|
45
|
+
|
|
46
|
+
Cookie 只保存在本机 `~/.dsh/foxy-weibo/settings.json`,不会回传给前端。插件优先使用 `m.weibo.cn`,失败时用 `weibo.com/ajax/statuses/mymblog` 作为备用接口。
|
|
47
|
+
|
|
48
|
+
### 自己的 UID / 关注 UID
|
|
49
|
+
|
|
50
|
+
`自己的微博 UID` 是主模式使用的账号,插件只检查它的新博文和评论。
|
|
51
|
+
|
|
52
|
+
`关注 UID` 是旧模式使用,多个 UID 用逗号分隔,最多 20 个。UID 是微博用户主页 URL 里的数字 ID,例如 `https://weibo.com/u/1234567890`。
|
|
53
|
+
|
|
54
|
+
### AI 模型
|
|
55
|
+
|
|
56
|
+
插件不经过 DSH 内置 agent,直接调用 OpenAI 兼容的 `/chat/completions`:
|
|
57
|
+
|
|
58
|
+
- Base URL:例如 `https://api.deepseek.com`
|
|
59
|
+
- 模型名:例如 `deepseek-chat`
|
|
60
|
+
- API Key:只保存在本机
|
|
61
|
+
|
|
62
|
+
系统提示词来自 `~/.dsh/foxy/presets/foxy3.yml` 的 `persona` 字段。
|
|
63
|
+
|
|
64
|
+
## 建议顺序
|
|
65
|
+
|
|
66
|
+
1. 扫码登录,填自己的微博 UID,`enabled: true`、`replyMode: off`;
|
|
67
|
+
2. 设置每日检查时间,或先点「立即运行」,看日志和「调试」里的模型决策;
|
|
68
|
+
3. 确认判断正常后,再开启 `replyMode: auto`,每日上限先设为 1;
|
|
69
|
+
4. 旧模式需要浏览他人博文时,再关闭「主模式」并配置关注 UID;
|
|
70
|
+
5. 微博评论有账号风控,不要高频、不要对陌生人全自动回复。
|
|
71
|
+
|
|
72
|
+
## 记忆事件
|
|
73
|
+
|
|
74
|
+
微博插件写入的示例:
|
|
75
|
+
|
|
76
|
+
- 浏览:`我在微博看到 <博主> 的博文「...」,觉得...`
|
|
77
|
+
- 评论:`我在微博评论了 <博主> 的博文「...」:...`
|
|
78
|
+
- 回复:`我在微博回复了 <网友> 在自己博文「...」下的评论:...`
|
|
79
|
+
|
|
80
|
+
事件字段:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"platform": "weibo",
|
|
85
|
+
"channelType": "weibo_post",
|
|
86
|
+
"channelId": "<post id>",
|
|
87
|
+
"eventType": "weibo_browse",
|
|
88
|
+
"sourceLabel": "微博",
|
|
89
|
+
"scene": "weibo"
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 限制
|
|
94
|
+
|
|
95
|
+
- 微博没有稳定的官方开放评论 API;Cookie 过期、风控或接口变更会导致读取/评论失败;
|
|
96
|
+
- 读取评论使用热门评论接口,可能不是全量;
|
|
97
|
+
- 评论是否显示受微博审核影响,插件只能提交,不能保证公开可见;
|
|
98
|
+
- 当前只做关注用户的时间线,不做搜索/超话/转发;
|
|
99
|
+
- 自动评论默认关闭。
|
|
100
|
+
|
|
101
|
+
## 开发
|
|
102
|
+
|
|
103
|
+
```powershell
|
|
104
|
+
node --check lib/index.js
|
|
105
|
+
node test/test-weibo.cjs
|
|
106
|
+
node test/test-client.cjs
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# dsh-foxy-weibo bundle patch.
|
|
2
|
+
# Install with:
|
|
3
|
+
# dsh plugin --profile desktop add file:D:\...\dsh-foxy-weibo
|
|
4
|
+
#
|
|
5
|
+
# After restart, open Settings -> Foxy-weibo. The dashboard writes
|
|
6
|
+
# ~/.dsh/foxy-weibo/settings.json; this YAML only provides first-run defaults.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: foxy-weibo
|
|
9
|
+
name: dsh-foxy-weibo
|
|
10
|
+
config:
|
|
11
|
+
enabled: false
|
|
12
|
+
cookie: ''
|
|
13
|
+
selfUid: ''
|
|
14
|
+
targetUids: []
|
|
15
|
+
replyOwnPosts: true
|
|
16
|
+
replyMode: 'off'
|
|
17
|
+
scheduleTimes:
|
|
18
|
+
- '20:00'
|
|
19
|
+
intervalMinutes: 60
|
|
20
|
+
maxPostsPerRun: 5
|
|
21
|
+
commentMode: 'off'
|
|
22
|
+
maxCommentsPerDay: 1
|
|
23
|
+
replyOthers: true
|
|
24
|
+
enableMemory: true
|
|
25
|
+
memoryPresetId: 'foxy3(Character)'
|
|
26
|
+
llmBaseUrl: 'https://api.deepseek.com'
|
|
27
|
+
llmModel: 'deepseek-chat'
|
|
28
|
+
runOnStart: false
|
|
29
|
+
debug: false
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-foxy-weibo',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
const API = '/api/dsh-foxy-weibo'
|
|
7
|
+
const PANEL_ID = 'dsh-foxy-weibo'
|
|
8
|
+
|
|
9
|
+
async function request(path, options = {}) {
|
|
10
|
+
const response = await fetch(API + path, {
|
|
11
|
+
headers: { 'content-type': 'application/json', ...(options.headers || {}) },
|
|
12
|
+
...options,
|
|
13
|
+
})
|
|
14
|
+
let body = {}
|
|
15
|
+
try {
|
|
16
|
+
body = await response.json()
|
|
17
|
+
} catch {}
|
|
18
|
+
if (!response.ok || body.ok === false) throw new Error(body.error || `HTTP ${response.status}`)
|
|
19
|
+
return body
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const css = `
|
|
23
|
+
.fw-page { padding: 28px 32px 44px; max-width: 880px; color: var(--dsw-alias-label-primary, #1f2328); height: 100%; overflow: auto; box-sizing: border-box; overflow-anchor: none; }
|
|
24
|
+
.fw-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
|
25
|
+
.fw-eyebrow { margin: 0 0 6px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; }
|
|
26
|
+
.fw-head h1 { margin: 0; font-size: 22px; line-height: 1.25; }
|
|
27
|
+
.fw-head p { margin: 6px 0 0; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 12px; }
|
|
28
|
+
.fw-badge { flex: none; border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 999px; padding: 4px 10px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; }
|
|
29
|
+
.fw-card { margin-bottom: 14px; padding: 18px 20px; border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 12px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
30
|
+
.fw-card h2 { margin: 0; font-size: 15px; }
|
|
31
|
+
.fw-card > p { margin: 5px 0 14px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 12px; line-height: 1.6; }
|
|
32
|
+
.fw-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 18px; }
|
|
33
|
+
.fw-field { min-width: 0; }
|
|
34
|
+
.fw-field label { display: block; color: var(--dsw-alias-label-secondary, #4b5563); font-size: 12px; font-weight: 600; }
|
|
35
|
+
.fw-field small { display: block; margin-top: 3px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; line-height: 1.45; }
|
|
36
|
+
.fw-field input, .fw-field select { width: 100%; box-sizing: border-box; margin-top: 5px; padding: 7px 9px; border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 7px; background: var(--dsw-alias-bg-layer-2, #f7f7f8); color: inherit; font: inherit; font-size: 12px; }
|
|
37
|
+
.fw-field textarea { width: 100%; box-sizing: border-box; margin-top: 5px; padding: 8px 9px; min-height: 90px; resize: vertical; border: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); border-radius: 7px; background: var(--dsw-alias-bg-layer-2, #f7f7f8); color: inherit; font: inherit; font-size: 12px; }
|
|
38
|
+
.fw-toggle { display: flex; position: relative; align-items: flex-start; gap: 10px; margin-top: 12px; cursor: pointer; }
|
|
39
|
+
.fw-toggle button { position: relative; flex: none; width: 32px; height: 18px; margin-top: 1px; border: 0; border-radius: 999px; background: var(--dsw-alias-bg-layer-3, #d1d5db); padding: 0; cursor: pointer; }
|
|
40
|
+
.fw-toggle button::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s ease; }
|
|
41
|
+
.fw-toggle[data-on="true"] button { background: #2f7d4a; }
|
|
42
|
+
.fw-toggle[data-on="true"] button::after { transform: translateX(14px); }
|
|
43
|
+
.fw-toggle strong { display: block; font-size: 12px; }
|
|
44
|
+
.fw-toggle small { display: block; margin-top: 2px; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; }
|
|
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; }
|
|
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; }
|
|
50
|
+
.fw-btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
51
|
+
.fw-btn:disabled { opacity: .55; cursor: default; }
|
|
52
|
+
.fw-alert { margin: 10px 0 0; padding: 8px 10px; border-radius: 7px; font-size: 12px; }
|
|
53
|
+
.fw-error { color: #a33; background: rgba(180, 40, 40, .08); }
|
|
54
|
+
.fw-ok { color: #2f7d4a; background: rgba(40, 130, 70, .08); }
|
|
55
|
+
.fw-muted { margin: 8px 0; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 12px; }
|
|
56
|
+
.fw-stats { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 12px; }
|
|
57
|
+
.fw-stats div { min-width: 80px; }
|
|
58
|
+
.fw-stats strong { display: block; font-size: 18px; }
|
|
59
|
+
.fw-stats small { color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; }
|
|
60
|
+
.fw-log { margin-top: 10px; border-top: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); }
|
|
61
|
+
.fw-row { padding: 9px 0; border-bottom: 1px solid var(--dsw-alias-bg-layer-3, #e5e7eb); }
|
|
62
|
+
.fw-row a { display: block; color: inherit; font-size: 12px; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
63
|
+
.fw-row a:hover { text-decoration: underline; }
|
|
64
|
+
.fw-row small, .fw-row p { color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px; }
|
|
65
|
+
.fw-row p { margin: 3px 0 0; }
|
|
66
|
+
.fw-debug pre { margin: 5px 0 0; max-height: 140px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 10px; }
|
|
67
|
+
@media (max-width: 720px) { .fw-page { padding: 20px 16px 32px; } .fw-grid { grid-template-columns: 1fr; } }
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
function Icon({ size = 18 }) {
|
|
71
|
+
return h(
|
|
72
|
+
'svg',
|
|
73
|
+
{ width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
74
|
+
h('path', { d: 'M4 6h16v10H9l-5 4V6Z' }),
|
|
75
|
+
h('path', { d: 'M8 10h8M8 13h5' }),
|
|
76
|
+
h('circle', { cx: 18, cy: 5, r: 2 }),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Toggle({ checked, onChange, label, hint, disabled }) {
|
|
81
|
+
return h(
|
|
82
|
+
'div',
|
|
83
|
+
{ className: 'fw-toggle', 'data-on': checked ? 'true' : 'false' },
|
|
84
|
+
h('button', { type: 'button', role: 'switch', 'aria-checked': !!checked, disabled, onClick: () => onChange(!checked) }),
|
|
85
|
+
h('span', null, h('strong', null, label), hint ? h('small', null, hint) : null),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Field({ label, hint, children }) {
|
|
90
|
+
return h('div', { className: 'fw-field' }, h('label', null, label), hint ? h('small', null, hint) : null, children)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function SectionHead({ title, description }) {
|
|
94
|
+
return h('div', null, h('h2', null, title), description ? h('p', null, description) : null)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Panel() {
|
|
98
|
+
const [settings, setSettings] = React.useState(null)
|
|
99
|
+
const [status, setStatus] = React.useState(null)
|
|
100
|
+
const [cookieDraft, setCookieDraft] = React.useState('')
|
|
101
|
+
const [apiKeyDraft, setApiKeyDraft] = React.useState('')
|
|
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)
|
|
107
|
+
const [busy, setBusy] = React.useState('')
|
|
108
|
+
const [error, setError] = React.useState('')
|
|
109
|
+
const [notice, setNotice] = React.useState('')
|
|
110
|
+
const mounted = React.useRef(true)
|
|
111
|
+
|
|
112
|
+
const refresh = React.useCallback(async () => {
|
|
113
|
+
const data = await request('/status')
|
|
114
|
+
if (!mounted.current) return
|
|
115
|
+
setSettings(data.settings)
|
|
116
|
+
setStatus(data.status)
|
|
117
|
+
setTargetText((data.settings?.targetUids || []).join(', '))
|
|
118
|
+
setScheduleText((data.settings?.scheduleTimes || []).join(', '))
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
mounted.current = true
|
|
123
|
+
refresh().catch((e) => setError(e.message))
|
|
124
|
+
return () => {
|
|
125
|
+
mounted.current = false
|
|
126
|
+
if (qrTimer.current) clearTimeout(qrTimer.current)
|
|
127
|
+
}
|
|
128
|
+
}, [refresh])
|
|
129
|
+
|
|
130
|
+
const setField = (key, value) => setSettings((current) => (current ? { ...current, [key]: value } : current))
|
|
131
|
+
|
|
132
|
+
const save = async () => {
|
|
133
|
+
if (!settings) return
|
|
134
|
+
setBusy('save'); setError(''); setNotice('')
|
|
135
|
+
try {
|
|
136
|
+
const patch = { ...settings, targetUids: targetText, scheduleTimes: scheduleText }
|
|
137
|
+
if (cookieDraft.trim()) patch.cookie = cookieDraft.trim()
|
|
138
|
+
if (apiKeyDraft.trim()) patch.llmApiKey = apiKeyDraft.trim()
|
|
139
|
+
const data = await request('/settings', { method: 'POST', body: JSON.stringify(patch) })
|
|
140
|
+
setSettings(data.settings)
|
|
141
|
+
setStatus(data.status)
|
|
142
|
+
setCookieDraft('')
|
|
143
|
+
setApiKeyDraft('')
|
|
144
|
+
setNotice('设置已保存')
|
|
145
|
+
} catch (e) { setError(e.message) } finally { setBusy('') }
|
|
146
|
+
}
|
|
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
|
+
|
|
210
|
+
const runNow = async () => {
|
|
211
|
+
setBusy('run'); setError(''); setNotice('')
|
|
212
|
+
try {
|
|
213
|
+
const data = await request('/run', { method: 'POST', body: '{}' })
|
|
214
|
+
setStatus(data.status)
|
|
215
|
+
const result = data.result || {}
|
|
216
|
+
setNotice(
|
|
217
|
+
result.skipped === 'disabled'
|
|
218
|
+
? '未开始:自动浏览已关闭'
|
|
219
|
+
: result.skipped === 'busy'
|
|
220
|
+
? '正在运行,请稍候'
|
|
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
|
+
if (!settings) return h('div', { className: 'fw-page' }, h('p', { className: 'fw-muted' }, error || '正在读取微博设置…'))
|
|
231
|
+
|
|
232
|
+
const recent = Array.isArray(status?.recent) ? status.recent : []
|
|
233
|
+
const debugEvents = Array.isArray(status?.debug) ? status.debug : []
|
|
234
|
+
return h(
|
|
235
|
+
'div',
|
|
236
|
+
{ className: 'fw-page' },
|
|
237
|
+
h('style', null, css),
|
|
238
|
+
h('div', { className: 'fw-head' },
|
|
239
|
+
h('div', null,
|
|
240
|
+
h('p', { className: 'fw-eyebrow' }, 'FOXY EXTENSION'),
|
|
241
|
+
h('h1', null, '微博浏览'),
|
|
242
|
+
h('p', null, 'Foxy 按 foxy3 预设关注微博账号,阅读、判断、评论并写入记忆。'),
|
|
243
|
+
),
|
|
244
|
+
h('span', { className: 'fw-badge' }, settings.enabled ? '自动浏览已开启' : '自动浏览已关闭'),
|
|
245
|
+
),
|
|
246
|
+
|
|
247
|
+
h('section', { className: 'fw-card' },
|
|
248
|
+
h(SectionHead, { title: '账号与登录', description: '扫码登录后即可回复评论;Cookie 输入保留为备用方式。' }),
|
|
249
|
+
h('div', { className: 'fw-grid' },
|
|
250
|
+
h(Field, { label: '当前账号', hint: '扫码成功后自动显示' },
|
|
251
|
+
h('input', { value: settings.accountName ? `${settings.accountName}(${settings.accountUid || '?'})` : settings.cookieSet ? '已登录' : '未登录', readOnly: true }),
|
|
252
|
+
),
|
|
253
|
+
h(Field, { label: '自己的微博 UID', hint: '主功能会检查这个账号的新博文评论' },
|
|
254
|
+
h('input', { value: settings.selfUid || '', placeholder: '例如 1234567890', onChange: (e) => setField('selfUid', e.target.value) })),
|
|
255
|
+
),
|
|
256
|
+
h('div', { className: 'fw-actions' },
|
|
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' ? '退出中…' : '退出登录'),
|
|
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) })),
|
|
268
|
+
),
|
|
269
|
+
|
|
270
|
+
h('section', { className: 'fw-card' },
|
|
271
|
+
h(SectionHead, { title: 'AI 模型', description: '直连 OpenAI 兼容 API;Key 只保存在本机,不回传前端。' }),
|
|
272
|
+
h('div', { className: 'fw-grid' },
|
|
273
|
+
h(Field, { label: '接口地址', hint: '自动补 /chat/completions' },
|
|
274
|
+
h('input', { value: settings.llmBaseUrl || '', onChange: (e) => setField('llmBaseUrl', e.target.value) })),
|
|
275
|
+
h(Field, { label: '模型名', hint: '例如 deepseek-chat' },
|
|
276
|
+
h('input', { value: settings.llmModel || '', onChange: (e) => setField('llmModel', e.target.value) })),
|
|
277
|
+
h(Field, { label: 'API Key', hint: settings.llmApiKeySet ? '已保存;留空表示不修改' : '必填,否则无法判断' },
|
|
278
|
+
h('input', { type: 'password', value: apiKeyDraft, placeholder: settings.llmApiKeySet ? '已保存,留空不修改' : 'sk-...', onChange: (e) => setApiKeyDraft(e.target.value) })),
|
|
279
|
+
),
|
|
280
|
+
),
|
|
281
|
+
|
|
282
|
+
h('section', { className: 'fw-card' },
|
|
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: '关闭后回到旧模式,浏览关注账号的时间线并评论博文。' }),
|
|
286
|
+
h('div', { className: 'fw-grid' },
|
|
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' },
|
|
295
|
+
h('input', { type: 'number', min: 1, max: 20, value: settings.maxPostsPerRun, onChange: (e) => setField('maxPostsPerRun', e.target.value) })),
|
|
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: '浏览他人博文时是否发一级评论' },
|
|
303
|
+
h('select', { value: settings.commentMode, onChange: (e) => setField('commentMode', e.target.value) },
|
|
304
|
+
h('option', { value: 'off' }, '仅记录'),
|
|
305
|
+
h('option', { value: 'auto' }, '自动评论'),
|
|
306
|
+
)),
|
|
307
|
+
),
|
|
308
|
+
h(Toggle, { checked: settings.replyOthers, onChange: (v) => setField('replyOthers', v), label: '回复他人评论', hint: '旧模式可从评论区选择一条评论回复。' }),
|
|
309
|
+
h(Toggle, { checked: settings.enableMemory, onChange: (v) => setField('enableMemory', v), label: '写入 Foxy 记忆', hint: '通过 foxyMemory 写入微博事件,按 preset 召回。' }),
|
|
310
|
+
h(Toggle, { checked: settings.runOnStart, onChange: (v) => setField('runOnStart', v), label: '启动 DSH 后自动运行', hint: '启动约 20 秒后执行一轮。' }),
|
|
311
|
+
h(Toggle, { checked: settings.debug, onChange: (v) => setField('debug', v), label: '记录调试日志', hint: '保存模型输出与记忆写入结果,便于排查。' }),
|
|
312
|
+
h('div', { className: 'fw-actions' },
|
|
313
|
+
h('button', { className: 'fw-btn primary', disabled: !!busy, onClick: runNow }, busy === 'run' ? '正在运行…' : '立即运行'),
|
|
314
|
+
h('button', { className: 'fw-btn', disabled: !!busy, onClick: save }, busy === 'save' ? '保存中…' : '保存设置'),
|
|
315
|
+
),
|
|
316
|
+
h('div', { className: 'fw-stats' },
|
|
317
|
+
h('div', null, h('strong', null, status?.seen || 0), h('small', null, '已浏览')),
|
|
318
|
+
h('div', null, h('strong', null, status?.commentsToday || 0), h('small', null, '今日评论')),
|
|
319
|
+
h('div', null, h('strong', null, status?.targetCount || 0), h('small', null, '关注账号')),
|
|
320
|
+
h('div', null, h('strong', null, status?.lastRun ? new Date(status.lastRun).toLocaleString() : '尚未运行'), h('small', null, '上次运行')),
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
|
|
324
|
+
h('section', { className: 'fw-card' },
|
|
325
|
+
h(SectionHead, { title: '运行与日志', description: '记录阅读过的博文、判断、评论与记忆结果。' }),
|
|
326
|
+
recent.length
|
|
327
|
+
? h('div', { className: 'fw-log' }, recent.map((item, index) =>
|
|
328
|
+
h('div', { className: 'fw-row', key: `${item.at}-${index}` },
|
|
329
|
+
h('a', { href: item.url, target: '_blank', rel: 'noreferrer' }, item.title || item.post),
|
|
330
|
+
h('small', null, `${item.author || ''} · ${new Date(item.at).toLocaleString()} · ${item.action}${item.memory ? ` · 记忆 ${item.memory}` : ''}`),
|
|
331
|
+
item.comment ? h('p', null, item.comment) : null,
|
|
332
|
+
item.error ? h('p', null, item.error) : null,
|
|
333
|
+
),
|
|
334
|
+
))
|
|
335
|
+
: h('p', { className: 'fw-muted' }, '暂无运行记录。'),
|
|
336
|
+
debugEvents.length
|
|
337
|
+
? h('div', { className: 'fw-debug' }, debugEvents.map((event, index) =>
|
|
338
|
+
h('div', { className: 'fw-row', key: `${event.at}-${index}` },
|
|
339
|
+
h('small', null, `${new Date(event.at).toLocaleString()} · ${event.kind || 'event'}${event.post ? ` · ${event.post}` : ''}`),
|
|
340
|
+
h('p', null, [event.action ? `动作 ${event.action}` : '', event.memory || '', event.reason || ''].filter(Boolean).join(' · ')),
|
|
341
|
+
event.raw ? h('pre', null, event.raw) : null,
|
|
342
|
+
),
|
|
343
|
+
))
|
|
344
|
+
: null,
|
|
345
|
+
),
|
|
346
|
+
|
|
347
|
+
error ? h('p', { className: 'fw-alert fw-error' }, error) : null,
|
|
348
|
+
notice ? h('p', { className: 'fw-alert fw-ok' }, notice) : null,
|
|
349
|
+
h('p', { className: 'fw-muted' }, '微博评论有账号风控风险。建议先用「仅记录」观察,再开启自动评论;回复功能默认关闭。'),
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function apply(ctx) {
|
|
354
|
+
ctx.effect(() => {
|
|
355
|
+
const style = document.createElement('style')
|
|
356
|
+
style.setAttribute('data-plugin', 'dsh-foxy-weibo')
|
|
357
|
+
style.textContent = css
|
|
358
|
+
document.head.appendChild(style)
|
|
359
|
+
return () => style.remove()
|
|
360
|
+
})
|
|
361
|
+
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, inject: () => ({ workbench: true }) }, () => h(Panel)))
|
|
362
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 14, label: () => 'Foxy-weibo' }, Icon))
|
|
363
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'foxy-weibo', order: 32, label: 'Foxy-weibo' }, () => h(Panel)))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { name: 'dsh-foxy-weibo', inject: ['slots'], apply }
|
|
367
|
+
},
|
|
368
|
+
})
|