cortico-world-mosey 1.2.0 → 1.3.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/CHANGELOG.md +10 -0
- package/README.md +15 -0
- package/describe.json +27 -2
- package/docs/life-section.png +0 -0
- package/docs/panel-preview.html +63 -0
- package/docs/panel-preview.png +0 -0
- package/package.json +5 -4
- package/src/ENV_PROMPT.md +19 -0
- package/src/bilibili.js +52 -4
- package/src/douyin.js +55 -0
- package/src/tools.js +7 -3
- package/src/world.js +310 -7
- package/webui/client.js +102 -16
- package/webui/style.css +111 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
- **新增:关注流**。`stroll action=following` 一次拿到关注的人在发什么(B站),面板「她的生活」里也会留最近一份。B站走动态流接口;抖音那个页面在后台渲染是空壳,读不到就如实说,不拿热门充数
|
|
6
|
+
- **新增:周报**。`stroll action=weekly`,全本地算最近 7 天:出门几次、评论几条、看谁最多、最活跃时段、连续几天。不花 token,面板一张卡
|
|
7
|
+
- **新增:本地书库**。把 txt/md 丢进 `stroll/books/`,她就能一段一段读:`books` 看书目、`read` 读 900 字、`note` 存一句摘抄。会记进度、接上次、不假装读过后面
|
|
8
|
+
- **面板新增「她的生活」区块**:关注流、在读、这周三张卡;关注流点一条直接在你的浏览器打开
|
|
9
|
+
- **修:B站登录没有入口**。以前面板只有「刷新登录态」,而刷新依赖一个已经打开的窗口,所以永远失败,还提示「先点『登录 B站』扫码」——那个按钮根本不存在。现在未登录时显示「登录 B站」,已登录显示「重新登录」
|
|
10
|
+
- **修:扫码扫到一半窗口被关**。登录窗以前借用「一起看」的窗口,而拉取消息时会 60 秒自动关页。现在登录窗单独开,扫码期间不自动关
|
|
11
|
+
- **修:登录失败说不清卡在哪**。现在分阶段报:没窗口 / 没收到 cookie / 身份校验没过(风控)。cookie 已经存下但校验失败时会明说「已保存」,不用重扫
|
|
12
|
+
|
|
3
13
|
## 1.2.0
|
|
4
14
|
|
|
5
15
|
- **修:动态下面的评论回不进去**。以前只认 BV 号,动态/专栏的消息一律被挡下。现在会先读一次动态详情,拿到真正的评论区 `oid / type`(图文动态是 11 + 相册 id,视频动态是 1 + aid),再带上 `root / parent` 回到楼中楼
|
package/README.md
CHANGED
|
@@ -52,8 +52,18 @@
|
|
|
52
52
|
- 面板「谁找你」:**点一条就用你自己的浏览器打开,并直接跳到那条评论**(`#reply<rpid>` 深链);能展开全部 15 条、按「评论 / @ 我」筛选、标已读、顺手回一句
|
|
53
53
|
- **动态/专栏的评论也能回**:视频、动态、专栏用同一套回复逻辑;面板里点「回一句」直接发到楼里,不用再跳网页
|
|
54
54
|
- `search`:B站搜索(WBI 签名)
|
|
55
|
+
- `following`:关注的人在发什么。B站走动态流接口(要登录),带作者、标题、发布时间和链接;抖音那边的关注页在后台是空壳,读不到会直言,不拿热门凑数
|
|
56
|
+
- `weekly`:最近 7 天的周报,全本地算:出门几次、评论几条、看谁最多、最活跃时段、连续几天。不花 token
|
|
55
57
|
- `history`:最近做过什么、草稿、兴趣画像、风控状态
|
|
56
58
|
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
### 读(本地书库)
|
|
62
|
+
- 把 `txt` / `md` 放进 `<数据目录>/stroll/books/`,她就能一段一段地读:`books` 看书目、`read` 读一段(默认 900 字)、`note` 存一句摘抄
|
|
63
|
+
- 会记进度、接着上次读、读到结尾会说读完了;文件不是 UTF-8 会提示另存,不硬读乱码
|
|
64
|
+
- **她只读这一段**:不会假装读过后面,也不复述剧情。想让她聊书,先让她读
|
|
65
|
+
- 面板「她的生活 → 在读」显示书名、进度条、已读字数和最近一句摘抄
|
|
66
|
+
|
|
57
67
|
### 动(全部有额度)
|
|
58
68
|
- 点赞 / 取消赞
|
|
59
69
|
- 关注 / 取关(默认每天 3 个,需批准)
|
|
@@ -233,7 +243,12 @@ corepack pnpm install
|
|
|
233
243
|
{"action":"comments","url":"https://www.bilibili.com/opus/1260000000000000000","n":10}
|
|
234
244
|
{"action":"feed","platform":"douyin","n":4}
|
|
235
245
|
{"action":"inbox","n":5}
|
|
246
|
+
{"action":"following","platform":"bilibili","n":6}
|
|
247
|
+
{"action":"weekly","days":7}
|
|
248
|
+
{"action":"books"}
|
|
249
|
+
{"action":"read","book":"夜航西飞","chars":900}
|
|
236
250
|
{"action":"search","query":"独立游戏"}
|
|
251
|
+
{"action":"note","text":"夜里飞过的地方,白天都不认得。"}
|
|
237
252
|
{"action":"history"}
|
|
238
253
|
```
|
|
239
254
|
|
package/describe.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"tools": [
|
|
3
3
|
{
|
|
4
4
|
"name": "stroll",
|
|
5
|
-
"description": "出门看看(只读,默认后台,不弹网页)。action=status 查登录/额度;brief 一次拿全(推荐,省 token);feed 刷候选;open 后台看指定视频/动态/专栏(B站链接都行);comments 只读评论区;inbox 看谁回了你;search 搜 B站;watch 一起看(watch_action=start/stop/status,start 才开窗);login 打开登录窗;history 看最近做过什么。先 brief,少反复 status。",
|
|
5
|
+
"description": "出门看看(只读,默认后台,不弹网页)。action=status 查登录/额度;brief 一次拿全(推荐,省 token);feed 刷候选;following 看关注的人在发什么(B站/抖音);open 后台看指定视频/动态/专栏(B站链接都行);comments 只读评论区;inbox 看谁回了你;search 搜 B站;weekly 看这周小结;books 看书库、read 读一段、note 记一句摘抄;watch 一起看(watch_action=start/stop/status,start 才开窗);login 打开登录窗;history 看最近做过什么。先 brief,少反复 status。",
|
|
6
6
|
"parameters": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
@@ -13,10 +13,15 @@
|
|
|
13
13
|
"status",
|
|
14
14
|
"brief",
|
|
15
15
|
"feed",
|
|
16
|
+
"following",
|
|
16
17
|
"open",
|
|
17
18
|
"comments",
|
|
18
19
|
"inbox",
|
|
19
20
|
"search",
|
|
21
|
+
"weekly",
|
|
22
|
+
"books",
|
|
23
|
+
"read",
|
|
24
|
+
"note",
|
|
20
25
|
"login",
|
|
21
26
|
"history",
|
|
22
27
|
"whoami",
|
|
@@ -47,9 +52,29 @@
|
|
|
47
52
|
"n": {
|
|
48
53
|
"type": "integer",
|
|
49
54
|
"minimum": 1,
|
|
50
|
-
"maximum":
|
|
55
|
+
"maximum": 20,
|
|
51
56
|
"description": "要几条,默认 6。"
|
|
52
57
|
},
|
|
58
|
+
"days": {
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"minimum": 1,
|
|
61
|
+
"maximum": 30,
|
|
62
|
+
"description": "weekly 看最近几天,默认 7。"
|
|
63
|
+
},
|
|
64
|
+
"book": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"description": "read 用:书名(不带后缀也行);不填就接着上次那本。"
|
|
67
|
+
},
|
|
68
|
+
"chars": {
|
|
69
|
+
"type": "integer",
|
|
70
|
+
"minimum": 200,
|
|
71
|
+
"maximum": 2000,
|
|
72
|
+
"description": "read 一次读多少字,默认 900。"
|
|
73
|
+
},
|
|
74
|
+
"text": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "note 用:要记下来的那句摘抄。"
|
|
77
|
+
},
|
|
53
78
|
"frames": {
|
|
54
79
|
"type": "integer",
|
|
55
80
|
"minimum": 0,
|
|
Binary file
|
package/docs/panel-preview.html
CHANGED
|
@@ -413,6 +413,69 @@ const fake = {
|
|
|
413
413
|
duration: 124,
|
|
414
414
|
},
|
|
415
415
|
viewed,
|
|
416
|
+
following: {
|
|
417
|
+
at: Date.now() - 12 * 60 * 1000,
|
|
418
|
+
total: 4,
|
|
419
|
+
items: [
|
|
420
|
+
{
|
|
421
|
+
platform: 'bilibili',
|
|
422
|
+
author: '穹和黑助',
|
|
423
|
+
title: '阴天,窗台,外面云压得很低,田里也没什么动静。',
|
|
424
|
+
at: Date.now() - 26 * 60 * 1000,
|
|
425
|
+
url: 'https://t.bilibili.com/1260000000000000000',
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
platform: 'douyin',
|
|
429
|
+
author: '阿九',
|
|
430
|
+
title: '凌晨三点的便利店',
|
|
431
|
+
at: 0,
|
|
432
|
+
url: 'https://www.douyin.com/video/7690000000000000000',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
platform: 'bilibili',
|
|
436
|
+
author: '程艾影',
|
|
437
|
+
title: '我把旧电脑装回了 Windows 98',
|
|
438
|
+
at: Date.now() - 5 * 3600 * 1000,
|
|
439
|
+
url: 'https://www.bilibili.com/video/BV1Mm4y1771E',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
platform: 'bilibili',
|
|
443
|
+
author: '有点诣思',
|
|
444
|
+
title: '拍了三张图,只有一张能用',
|
|
445
|
+
at: Date.now() - 9 * 3600 * 1000,
|
|
446
|
+
url: 'https://t.bilibili.com/1260000000000000000',
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
books: {
|
|
451
|
+
empty: false,
|
|
452
|
+
total: 3,
|
|
453
|
+
dir: '…/stroll/books',
|
|
454
|
+
current: {
|
|
455
|
+
name: '夜航西飞.txt',
|
|
456
|
+
percent: 38,
|
|
457
|
+
offset: 21400,
|
|
458
|
+
total: 56300,
|
|
459
|
+
notes: [{ text: '夜里飞过的地方,白天都不认得。', at: Date.now() }],
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
weekly: {
|
|
463
|
+
ok: true,
|
|
464
|
+
days: 7,
|
|
465
|
+
range: { from: '2026-10-26', to: '2026-11-01' },
|
|
466
|
+
totalActions: 13,
|
|
467
|
+
actions: { like: 5, comment: 3, reply: 1, follow: 1, post: 1, dm: 1, save: 1 },
|
|
468
|
+
reads: 14,
|
|
469
|
+
views: 9,
|
|
470
|
+
activeDays: 5,
|
|
471
|
+
platforms: [
|
|
472
|
+
{ name: 'B站', count: 9 },
|
|
473
|
+
{ name: '抖音', count: 4 },
|
|
474
|
+
],
|
|
475
|
+
topAuthors: [{ name: '穹和黑助', count: 3 }],
|
|
476
|
+
busiestHour: { hour: 21, count: 4 },
|
|
477
|
+
headline: '7 天里出门 13 次、看书 14 段,主要在 B站',
|
|
478
|
+
},
|
|
416
479
|
todayReads: 3,
|
|
417
480
|
autoSessions: 1,
|
|
418
481
|
autoNext: { done: false, used: 1, cap: 3, at: Date.now() + 83 * 60 * 1000 },
|
package/docs/panel-preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-mosey",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B
|
|
5
|
+
"description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。关注流、周报、本地书库;视频、动态、专栏都能看能评;按额度点赞、评论、关注、发动态、私信;默认人审,三把工具、少 token。",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"cortico-world",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"icon": "assets/icon.png",
|
|
40
40
|
"screenshots": [
|
|
41
41
|
"docs/panel-preview.png",
|
|
42
|
+
"docs/life-section.png",
|
|
42
43
|
"docs/detail-sheet.png",
|
|
43
44
|
"docs/comments-sheet.png"
|
|
44
45
|
],
|
|
@@ -55,11 +56,11 @@
|
|
|
55
56
|
"locales": {
|
|
56
57
|
"zh": {
|
|
57
58
|
"displayName": "摸鱼",
|
|
58
|
-
"description": "让她自己去抖音/B
|
|
59
|
+
"description": "让她自己去抖音/B站逛:刷候选、看关注流、看视频/动态/专栏、点赞、评论、回复楼中楼、关注、发动态、私信;还带周报和本地书库。公开动作有硬额度,默认先出草稿等你批准;三把工具,token 花得少。"
|
|
59
60
|
},
|
|
60
61
|
"en": {
|
|
61
62
|
"displayName": "Mosey",
|
|
62
|
-
"description": "Let her browse Douyin and Bilibili:
|
|
63
|
+
"description": "Let her browse Douyin and Bilibili: feeds, following updates, videos, dynamics and articles; like, comment, reply in threads, follow, post, DM. Plus weekly recap and a local book library. Hard caps and owner approval by default; only three tools."
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
},
|
package/src/ENV_PROMPT.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
兴趣:{{stroll.interests}}
|
|
14
14
|
上次:{{stroll.last}}
|
|
15
15
|
一起看:{{stroll.watch}}
|
|
16
|
+
读书:{{stroll.books}}
|
|
16
17
|
|
|
17
18
|
### 怎么逛(省 token)
|
|
18
19
|
|
|
@@ -28,6 +29,24 @@
|
|
|
28
29
|
- 主人让你点赞/评论/关注时:**直接调 `stroll_act`**,不要回答“我做不到”。它会按审批规则直接发或进草稿箱;进草稿箱也只是等主人点一下,不是你被拒绝了。
|
|
29
30
|
- 失败时看回执里的 `code`:`LOGIN_REQUIRED` 让主人去面板扫码;`RISK` 立刻停手;`NOT_CONFIRMED` 说明平台没给确认,不要连点第二次。
|
|
30
31
|
|
|
32
|
+
### 关注的人在发什么
|
|
33
|
+
|
|
34
|
+
- `stroll action=following` 一次拿到 B站和抖音的关注流;只挑 **1 条**真有话说的点开,别每条都看。
|
|
35
|
+
- 看到主人提过的名字、或者你熟悉的作者,可以顺口提一句;不要每条都汇报。
|
|
36
|
+
- 关注流没东西时(比如没登录),老实说没看到,不要拿热门充数。
|
|
37
|
+
|
|
38
|
+
### 周报
|
|
39
|
+
|
|
40
|
+
- 主人问「这周都干嘛了」时,先调 `stroll action=weekly`(本地算的,不花 token),再回答。
|
|
41
|
+
- 只挑一两个数字说,不要逐条念,也别把它当 KPI 汇报。
|
|
42
|
+
|
|
43
|
+
### 读书(本地书库)
|
|
44
|
+
|
|
45
|
+
- 先用 `stroll action=books` 看书库里有什么;`read` 读一段(默认 900 字)。
|
|
46
|
+
- 一次只读一段,读完说一句**你自己的**感受。**不要复述剧情、不要剧透、不要假装读过后面**。
|
|
47
|
+
- 想留一句就用 `action=note`,不要大段抄原文。
|
|
48
|
+
- 书库是空的时候就说「还没放书」,不要编书名,也不要假装在读。
|
|
49
|
+
|
|
31
50
|
### 一起看
|
|
32
51
|
|
|
33
52
|
- 主人说“一起看/陪我看/你看这个”时,调 `stroll action=watch watch_action=start` 并带上 `url`。会开一个窗口,主人在里面翻页、拖进度;你在后台读同一个页面。
|
package/src/bilibili.js
CHANGED
|
@@ -155,14 +155,19 @@ export class Bilibili {
|
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
157
|
* 从 摸鱼 自己的浏览器窗口里收 B站 cookie。
|
|
158
|
-
*
|
|
158
|
+
* 只在主人主动登录、或面板刷新时调用;不会去碰系统浏览器的资料。
|
|
159
|
+
* 返回带 stage 的结果,方便上层说清楚卡在哪一步(没窗口 / 没 cookie / 校验没过)。
|
|
159
160
|
*/
|
|
160
161
|
async importFromContext(context) {
|
|
161
|
-
if (!context)
|
|
162
|
+
if (!context) {
|
|
163
|
+
return { ok: false, stage: 'no-window', error: '还没有打开的浏览器窗口,先点「登录 B站」。' };
|
|
164
|
+
}
|
|
162
165
|
const cookies = await context.cookies('https://www.bilibili.com').catch(() => []);
|
|
163
166
|
const sess = cookies.find((c) => c.name === 'SESSDATA');
|
|
164
167
|
const jct = cookies.find((c) => c.name === 'bili_jct');
|
|
165
|
-
if (!sess?.value || !jct?.value)
|
|
168
|
+
if (!sess?.value || !jct?.value) {
|
|
169
|
+
return { ok: false, stage: 'no-cookie', error: '窗口里还没看到 B站登录 cookie,先在窗口里扫码。' };
|
|
170
|
+
}
|
|
166
171
|
const cred = {
|
|
167
172
|
sessdata: sess.value,
|
|
168
173
|
bili_jct: jct.value,
|
|
@@ -177,7 +182,16 @@ export class Bilibili {
|
|
|
177
182
|
}
|
|
178
183
|
this.setCredentials(cred);
|
|
179
184
|
const nav = await this.nav(true).catch(() => null);
|
|
180
|
-
|
|
185
|
+
if (nav?.isLogin) {
|
|
186
|
+
return { ok: true, stage: 'ok', loggedIn: true, uid: String(nav.mid ?? ''), name: nav.uname ?? '' };
|
|
187
|
+
}
|
|
188
|
+
// cookie 已经收下也存好了,只是这次校验没通过(多为风控),别让主人以为白扫了
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
stage: 'verify-failed',
|
|
192
|
+
saved: true,
|
|
193
|
+
error: 'cookie 已经保存,但身份校验没通过(多半是风控)。过一会儿点「重新登录」或刷新面板再试。',
|
|
194
|
+
};
|
|
181
195
|
}
|
|
182
196
|
|
|
183
197
|
readCredentials() {
|
|
@@ -373,6 +387,40 @@ export class Bilibili {
|
|
|
373
387
|
return this.rank(items).slice(0, limit);
|
|
374
388
|
}
|
|
375
389
|
|
|
390
|
+
/**
|
|
391
|
+
* 关注的人在发什么(动态流)。需要登录;没登录时接口返回空列表,不抛错。
|
|
392
|
+
*/
|
|
393
|
+
async followFeed(limit = 8) {
|
|
394
|
+
const data = await this.request(
|
|
395
|
+
'/x/polymer/web-dynamic/v1/feed/all',
|
|
396
|
+
{ type: 'all', page: 1, timezone_offset: -480 },
|
|
397
|
+
{ cacheMs: 90 * 1000 },
|
|
398
|
+
);
|
|
399
|
+
const list = Array.isArray(data.items) ? data.items : [];
|
|
400
|
+
return list.slice(0, Math.max(1, limit)).map((item) => {
|
|
401
|
+
const author = item.modules?.module_author ?? {};
|
|
402
|
+
const major = item.modules?.module_dynamic?.major ?? {};
|
|
403
|
+
const arch = major.archive;
|
|
404
|
+
const text = dynamicText(item);
|
|
405
|
+
const dynId = String(item.id_str ?? '');
|
|
406
|
+
const bvid = String(arch?.bvid ?? '');
|
|
407
|
+
const pics = dynamicImages(item).length;
|
|
408
|
+
return {
|
|
409
|
+
platform: 'bilibili',
|
|
410
|
+
kind: bvid ? 'video' : major.article ? 'article' : 'opus',
|
|
411
|
+
id: dynId,
|
|
412
|
+
bvid,
|
|
413
|
+
title: truncate(text || arch?.title || `${author.name ?? ''}发了${pics ? `${pics} 张图` : '一条动态'}`, 70),
|
|
414
|
+
author: stripTags(author.name ?? ''),
|
|
415
|
+
authorId: String(author.mid ?? ''),
|
|
416
|
+
at: Number(author.pub_ts ?? 0) * 1000,
|
|
417
|
+
url: bvid ? `https://www.bilibili.com/video/${bvid}` : `https://t.bilibili.com/${dynId}`,
|
|
418
|
+
stat: `${formatCount(Number(item.modules?.module_stat?.comment?.count ?? 0))} 评`,
|
|
419
|
+
pics,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
376
424
|
/** 稍后再看。空列表很常见,不当成错误。 */
|
|
377
425
|
async toview(limit = 8) {
|
|
378
426
|
const data = await this.request('/x/v2/history/toview', {}, { cacheMs: 60000 });
|
package/src/douyin.js
CHANGED
|
@@ -125,6 +125,7 @@ export class DouyinBrowser {
|
|
|
125
125
|
this.page = null;
|
|
126
126
|
this.queue = Promise.resolve();
|
|
127
127
|
this.closeTimer = null;
|
|
128
|
+
this.holdOpen = false;
|
|
128
129
|
this.launching = null;
|
|
129
130
|
this.lastAction = '';
|
|
130
131
|
this.lastError = '';
|
|
@@ -385,6 +386,35 @@ export class DouyinBrowser {
|
|
|
385
386
|
});
|
|
386
387
|
}
|
|
387
388
|
|
|
389
|
+
/** 关注流:读 /follow 页。没登录时返回空数组,由上层给提示。 */
|
|
390
|
+
async following(limit = 6) {
|
|
391
|
+
return this.serialize(async () => {
|
|
392
|
+
const page = await this.getPage();
|
|
393
|
+
await this.goto(page, `${ORIGIN}/follow`);
|
|
394
|
+
if (!(await this.isLoggedIn())) return [];
|
|
395
|
+
for (let i = 0; i < Math.max(1, Math.ceil(limit / 6)); i++) {
|
|
396
|
+
await page.mouse.wheel(0, 900 + Math.floor(Math.random() * 400));
|
|
397
|
+
await humanPause(1200, 2000);
|
|
398
|
+
}
|
|
399
|
+
const raw = await page.evaluate(feedScript(Math.min(20, Math.max(limit, 6))));
|
|
400
|
+
this.scheduleClose(45);
|
|
401
|
+
this.lastAction = `following ${raw.length}`;
|
|
402
|
+
return raw.slice(0, limit).map((item) => ({
|
|
403
|
+
platform: 'douyin',
|
|
404
|
+
kind: 'video',
|
|
405
|
+
id: String(item.id),
|
|
406
|
+
bvid: '',
|
|
407
|
+
title: truncate(item.title, 70),
|
|
408
|
+
author: truncate(item.author, 18),
|
|
409
|
+
authorId: '',
|
|
410
|
+
at: 0,
|
|
411
|
+
url: item.url,
|
|
412
|
+
stat: item.like ? `${item.like} 赞` : '',
|
|
413
|
+
cover: item.cover,
|
|
414
|
+
}));
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
388
418
|
mapCard(item) {
|
|
389
419
|
const tags = this.matchTags(`${item.title} ${item.author}`);
|
|
390
420
|
const durSec = this.durationOf(item.duration);
|
|
@@ -490,6 +520,8 @@ export class DouyinBrowser {
|
|
|
490
520
|
}
|
|
491
521
|
|
|
492
522
|
scheduleClose(seconds) {
|
|
523
|
+
// 登录窗开着时不自动关页:扫码可能要好几十秒
|
|
524
|
+
if (this.holdOpen) return;
|
|
493
525
|
if (this.closeTimer) clearTimeout(this.closeTimer);
|
|
494
526
|
if (!seconds || seconds <= 0) {
|
|
495
527
|
this.closeTimer = setTimeout(() => this.closePage().catch(() => {}), 1500);
|
|
@@ -756,6 +788,28 @@ export class DouyinBrowser {
|
|
|
756
788
|
});
|
|
757
789
|
}
|
|
758
790
|
|
|
791
|
+
/**
|
|
792
|
+
* 专门用来登录的窗口:开一个有头窗口停在登录页,期间不自动关页。
|
|
793
|
+
* 不复用「一起看」,免得扫码扫到一半被 scheduleClose 关掉。
|
|
794
|
+
*/
|
|
795
|
+
async loginWindow(url) {
|
|
796
|
+
return this.serialize(async () => {
|
|
797
|
+
await this.launch({ headless: false, force: true });
|
|
798
|
+
this.holdOpen = true;
|
|
799
|
+
if (this.closeTimer) clearTimeout(this.closeTimer);
|
|
800
|
+
this.closeTimer = null;
|
|
801
|
+
const page = await this.getPage();
|
|
802
|
+
if (url) await this.goto(page, url, 2200);
|
|
803
|
+
return { ok: true, url: page.url() };
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async loginWindowClose() {
|
|
808
|
+
this.holdOpen = false;
|
|
809
|
+
await this.close().catch(() => {});
|
|
810
|
+
return { ok: true };
|
|
811
|
+
}
|
|
812
|
+
|
|
759
813
|
/** 一起看:开一个有头窗口,主人可以自己在这个窗口里翻页,桌宠读同一个页面。 */
|
|
760
814
|
async watchStart(url) {
|
|
761
815
|
return this.serialize(async () => {
|
|
@@ -800,6 +854,7 @@ export class DouyinBrowser {
|
|
|
800
854
|
async close() {
|
|
801
855
|
if (this.closeTimer) clearTimeout(this.closeTimer);
|
|
802
856
|
this.closeTimer = null;
|
|
857
|
+
this.holdOpen = false;
|
|
803
858
|
await this.page?.close().catch(() => {});
|
|
804
859
|
this.page = null;
|
|
805
860
|
await this.context?.close().catch(() => {});
|
package/src/tools.js
CHANGED
|
@@ -4,7 +4,7 @@ export const TOOL_DECLS = [
|
|
|
4
4
|
{
|
|
5
5
|
name: 'stroll',
|
|
6
6
|
description:
|
|
7
|
-
'出门看看(只读,默认后台,不弹网页)。action=status 查登录/额度;brief 一次拿全(推荐,省 token);feed 刷候选;open 后台看指定视频/动态/专栏(B站链接都行);comments 只读评论区;inbox 看谁回了你;search 搜 B站;watch 一起看(watch_action=start/stop/status,start 才开窗);login 打开登录窗;history 看最近做过什么。先 brief,少反复 status。',
|
|
7
|
+
'出门看看(只读,默认后台,不弹网页)。action=status 查登录/额度;brief 一次拿全(推荐,省 token);feed 刷候选;following 看关注的人在发什么(B站/抖音);open 后台看指定视频/动态/专栏(B站链接都行);comments 只读评论区;inbox 看谁回了你;search 搜 B站;weekly 看这周小结;books 看书库、read 读一段、note 记一句摘抄;watch 一起看(watch_action=start/stop/status,start 才开窗);login 打开登录窗;history 看最近做过什么。先 brief,少反复 status。',
|
|
8
8
|
tags: ['read'],
|
|
9
9
|
parameters: {
|
|
10
10
|
type: 'object',
|
|
@@ -12,14 +12,18 @@ export const TOOL_DECLS = [
|
|
|
12
12
|
properties: {
|
|
13
13
|
action: {
|
|
14
14
|
type: 'string',
|
|
15
|
-
enum: ['status', 'brief', 'feed', 'open', 'comments', 'inbox', 'search', 'login', 'history', 'whoami', 'watch'],
|
|
15
|
+
enum: ['status', 'brief', 'feed', 'following', 'open', 'comments', 'inbox', 'search', 'weekly', 'books', 'read', 'note', 'login', 'history', 'whoami', 'watch'],
|
|
16
16
|
description: '要做什么。',
|
|
17
17
|
},
|
|
18
18
|
platform: { type: 'string', enum: ['bilibili', 'douyin'], description: '去哪个平台;open 会从链接自动判断。' },
|
|
19
19
|
url: { type: 'string', description: '视频 / 动态 / 专栏链接(open 用)。' },
|
|
20
20
|
id: { type: 'string', description: 'BV 号、av 号、动态 ID、cv 号或抖音视频 ID(open 用)。' },
|
|
21
21
|
query: { type: 'string', description: '搜索词(search 用)。' },
|
|
22
|
-
n: { type: 'integer', minimum: 1, maximum:
|
|
22
|
+
n: { type: 'integer', minimum: 1, maximum: 20, description: '要几条,默认 6。' },
|
|
23
|
+
days: { type: 'integer', minimum: 1, maximum: 30, description: 'weekly 看最近几天,默认 7。' },
|
|
24
|
+
book: { type: 'string', description: 'read 用:书名(不带后缀也行);不填就接着上次那本。' },
|
|
25
|
+
chars: { type: 'integer', minimum: 200, maximum: 2000, description: 'read 一次读多少字,默认 900。' },
|
|
26
|
+
text: { type: 'string', description: 'note 用:要记下来的那句摘抄。' },
|
|
23
27
|
frames: { type: 'integer', minimum: 0, maximum: 3, description: '抖音抽几帧;默认 0,最省 token。' },
|
|
24
28
|
watch_action: {
|
|
25
29
|
type: 'string',
|
package/src/world.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { Store } from './store.js';
|
|
@@ -171,9 +172,15 @@ export class StrollWorld {
|
|
|
171
172
|
? '日常:看 1-2 条,偶尔评一句'
|
|
172
173
|
: '安静:只看看,不主动评论';
|
|
173
174
|
const present = this.presenceView();
|
|
175
|
+
const book = this.bookView();
|
|
174
176
|
return {
|
|
175
177
|
'stroll.summary': this.policy.summary(),
|
|
176
178
|
'stroll.activity': activity,
|
|
179
|
+
'stroll.books': book.current
|
|
180
|
+
? `在读《${truncate(book.current.name.replace(/\.(txt|md|markdown)$/i, ''), 30)}》,已读 ${book.current.percent}%`
|
|
181
|
+
: book.empty
|
|
182
|
+
? '书库是空的(把 txt 放进面板上显示的那个目录)'
|
|
183
|
+
: '书库有书,但还没开始读',
|
|
177
184
|
'stroll.presence': present
|
|
178
185
|
? `正在${present.platform === 'douyin' ? '抖音' : 'B站'}看《${truncate(present.title || present.url, 40)}》`
|
|
179
186
|
: '现在没在看',
|
|
@@ -246,6 +253,11 @@ export class StrollWorld {
|
|
|
246
253
|
return JSON.stringify(await this.watchStatus());
|
|
247
254
|
}
|
|
248
255
|
if (action === 'login') return JSON.stringify(await this.login(args.platform));
|
|
256
|
+
if (action === 'following') return JSON.stringify(await this.followingFeed(args.platform, args.n));
|
|
257
|
+
if (action === 'weekly') return JSON.stringify(this.weeklyReport(args.days));
|
|
258
|
+
if (action === 'books') return JSON.stringify(this.listBooks());
|
|
259
|
+
if (action === 'read') return JSON.stringify(this.readBook({ name: args.book, chars: args.chars }));
|
|
260
|
+
if (action === 'note') return JSON.stringify(this.noteBook(args.text));
|
|
249
261
|
if (action === 'history') return JSON.stringify(this.history());
|
|
250
262
|
return JSON.stringify({ ok: false, error: `不认识的动作:${action}` });
|
|
251
263
|
}
|
|
@@ -512,26 +524,52 @@ export class StrollWorld {
|
|
|
512
524
|
|
|
513
525
|
async login(platform) {
|
|
514
526
|
if (platform === 'bilibili') {
|
|
515
|
-
|
|
527
|
+
// 窗口已经开着并且有 B站登录态,就先直接收,不再多开一个窗
|
|
528
|
+
if (this.douyin.context) {
|
|
529
|
+
const quick = await this.bili.importFromContext(this.douyin.context).catch(() => null);
|
|
530
|
+
if (quick?.ok) {
|
|
531
|
+
this.lastImportAt = Date.now();
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
name: quick.name,
|
|
535
|
+
uid: quick.uid,
|
|
536
|
+
message: `B站已经是登录状态:${quick.name || quick.uid || ''}。`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
let info;
|
|
541
|
+
try {
|
|
542
|
+
info = await this.douyin.loginWindow('https://passport.bilibili.com/login');
|
|
543
|
+
} catch (error) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
code: error?.code ?? 'BROWSER_LAUNCH',
|
|
547
|
+
error: `登录窗没打开:${error?.message ?? String(error)}。多半是没装 Edge / Chrome,或者上次的窗口没退干净。`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
516
550
|
// 扫码是异步的:后台轮询,登上了就把 cookie 收回来并关窗
|
|
517
551
|
let tries = 0;
|
|
518
552
|
const timer = setInterval(async () => {
|
|
519
553
|
tries += 1;
|
|
554
|
+
if (tries > 60 || !this.douyin.context) {
|
|
555
|
+
clearInterval(timer);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
520
558
|
try {
|
|
521
559
|
const got = await this.bili.importFromContext(this.douyin.context).catch(() => null);
|
|
522
|
-
if (got) {
|
|
560
|
+
if (got?.ok) {
|
|
523
561
|
clearInterval(timer);
|
|
524
|
-
|
|
562
|
+
this.lastImportAt = Date.now();
|
|
563
|
+
await this.douyin.loginWindowClose().catch(() => {});
|
|
525
564
|
await this.push('stroll.sent', `B站登录成功:${got.name || got.uid || '已登录'}。`, { kind: 'bili-login' }, 'flush');
|
|
526
565
|
}
|
|
527
566
|
} catch {
|
|
528
567
|
// 继续等
|
|
529
568
|
}
|
|
530
|
-
if (tries > 60) clearInterval(timer);
|
|
531
569
|
}, 3000);
|
|
532
570
|
return {
|
|
533
571
|
ok: true,
|
|
534
|
-
message: 'B
|
|
572
|
+
message: 'B站登录窗已经开了;在窗口里扫码,登上后会自动收回登录态并关窗。',
|
|
535
573
|
url: info.url,
|
|
536
574
|
};
|
|
537
575
|
}
|
|
@@ -550,6 +588,268 @@ export class StrollWorld {
|
|
|
550
588
|
};
|
|
551
589
|
}
|
|
552
590
|
|
|
591
|
+
/* ---------------- 关注流 / 周报 / 书 ---------------- */
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 关注的人在发什么。B站走动态流接口,抖音读 /follow 页面。
|
|
595
|
+
* 结果落一份到本地,面板不用每次都拉。
|
|
596
|
+
*/
|
|
597
|
+
async followingFeed(platform = 'both', n = 8) {
|
|
598
|
+
const want = platform === 'douyin' || platform === 'bilibili' ? platform : 'both';
|
|
599
|
+
const limit = clamp(n ?? 8, 1, 20);
|
|
600
|
+
const items = [];
|
|
601
|
+
const errors = [];
|
|
602
|
+
|
|
603
|
+
if (want !== 'douyin') {
|
|
604
|
+
if (!this.cfg.bilibili) errors.push({ platform: 'bilibili', error: 'B站关着' });
|
|
605
|
+
else if (!this.bili.readCredentials()) errors.push({ platform: 'bilibili', error: 'B站没登录,关注流要登录才看得到' });
|
|
606
|
+
else {
|
|
607
|
+
try {
|
|
608
|
+
items.push(...(await this.bili.followFeed(limit)));
|
|
609
|
+
} catch (error) {
|
|
610
|
+
errors.push({ platform: 'bilibili', error: error.message });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (want !== 'bilibili') {
|
|
616
|
+
if (!this.cfg.douyin) errors.push({ platform: 'douyin', error: '抖音关着' });
|
|
617
|
+
else {
|
|
618
|
+
try {
|
|
619
|
+
const got = await this.douyin.following(limit);
|
|
620
|
+
if (!got.length) errors.push({ platform: 'douyin', error: '抖音关注流读不到(后台页面是空壳);抖音这边用 feed 刷精选' });
|
|
621
|
+
items.push(...got);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
errors.push({ platform: 'douyin', error: error.message });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
items.sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0));
|
|
629
|
+
const list = items.slice(0, limit);
|
|
630
|
+
if (list.length) {
|
|
631
|
+
this.store.data.followFeed = { at: Date.now(), items: list.slice(0, 12) };
|
|
632
|
+
this.store.save();
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
ok: list.length > 0,
|
|
636
|
+
platform: want,
|
|
637
|
+
items: list,
|
|
638
|
+
errors: errors.length ? errors : undefined,
|
|
639
|
+
hint: list.length
|
|
640
|
+
? '挑一条真的想看的;不要每条都点开,也不要复述标题。'
|
|
641
|
+
: '关注流暂时没东西;可以先用 feed 刷候选。',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 本地缓存的一份关注流,给面板用。 */
|
|
646
|
+
followFeedView(n = 5) {
|
|
647
|
+
const cached = this.store.data.followFeed;
|
|
648
|
+
if (!cached?.items?.length) return null;
|
|
649
|
+
return { at: Number(cached.at ?? 0), items: cached.items.slice(0, n), total: cached.items.length };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** 最近 N 天的周报:全本地算,不花 token。 */
|
|
653
|
+
weeklyReport(days = 7) {
|
|
654
|
+
const span = clamp(days ?? 7, 1, 30);
|
|
655
|
+
const tz = this.timeZone;
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
const dayKeys = [];
|
|
658
|
+
for (let i = span - 1; i >= 0; i -= 1) dayKeys.push(dayKey(new Date(now - i * 86400000), tz));
|
|
659
|
+
|
|
660
|
+
const usage = this.store.data.usage ?? {};
|
|
661
|
+
const sum = { like: 0, unlike: 0, comment: 0, reply: 0, follow: 0, unfollow: 0, post: 0, dm: 0, save: 0 };
|
|
662
|
+
let reads = 0;
|
|
663
|
+
const perDay = [];
|
|
664
|
+
for (const key of dayKeys) {
|
|
665
|
+
const day = usage[key] ?? {};
|
|
666
|
+
let total = 0;
|
|
667
|
+
for (const k of Object.keys(sum)) {
|
|
668
|
+
const v = Number(day[k] ?? 0);
|
|
669
|
+
sum[k] += v;
|
|
670
|
+
total += v;
|
|
671
|
+
}
|
|
672
|
+
const read = Number(this.store.data.readDays?.[key] ?? 0);
|
|
673
|
+
reads += read;
|
|
674
|
+
perDay.push({ day: key.slice(5), actions: total, reads: read });
|
|
675
|
+
}
|
|
676
|
+
const totalActions = Object.values(sum).reduce((a, b) => a + b, 0);
|
|
677
|
+
|
|
678
|
+
const since = now - span * 86400000;
|
|
679
|
+
const acts = (this.store.data.actions ?? []).filter((a) => Date.parse(a.at ?? '') >= since);
|
|
680
|
+
const platformCount = new Map();
|
|
681
|
+
const hours = new Array(24).fill(0);
|
|
682
|
+
for (const a of acts) {
|
|
683
|
+
const p = a.platform === 'douyin' ? '抖音' : 'B站';
|
|
684
|
+
platformCount.set(p, (platformCount.get(p) ?? 0) + 1);
|
|
685
|
+
const ts = Date.parse(a.at ?? '');
|
|
686
|
+
if (Number.isFinite(ts)) hours[new Date(ts + 8 * 3600000).getUTCHours()] += 1;
|
|
687
|
+
}
|
|
688
|
+
const busiest = hours.reduce((best, v, i) => (v > hours[best] ? i : best), 0);
|
|
689
|
+
|
|
690
|
+
const authors = new Map();
|
|
691
|
+
const viewed = (this.store.data.viewed ?? []).filter((v) => Date.parse(v.at ?? '') >= since);
|
|
692
|
+
for (const v of viewed) {
|
|
693
|
+
const name = String(v.author ?? '').trim();
|
|
694
|
+
if (name) authors.set(name, (authors.get(name) ?? 0) + 1);
|
|
695
|
+
}
|
|
696
|
+
const topAuthors = [...authors.entries()]
|
|
697
|
+
.sort((a, b) => b[1] - a[1])
|
|
698
|
+
.slice(0, 3)
|
|
699
|
+
.map(([name, count]) => ({ name, count }));
|
|
700
|
+
|
|
701
|
+
const activeDays = perDay.filter((d) => d.actions > 0 || d.reads > 0).length;
|
|
702
|
+
const topPlatform = [...platformCount.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
ok: true,
|
|
706
|
+
days: span,
|
|
707
|
+
range: { from: dayKeys[0], to: dayKeys[dayKeys.length - 1] },
|
|
708
|
+
totalActions,
|
|
709
|
+
actions: sum,
|
|
710
|
+
reads,
|
|
711
|
+
views: viewed.length,
|
|
712
|
+
activeDays,
|
|
713
|
+
perDay,
|
|
714
|
+
platforms: [...platformCount.entries()].map(([name, count]) => ({ name, count })),
|
|
715
|
+
topAuthors,
|
|
716
|
+
busiestHour: totalActions ? { hour: busiest, count: hours[busiest] } : null,
|
|
717
|
+
headline: totalActions || reads
|
|
718
|
+
? `${span} 天里出门 ${totalActions} 次、看书 ${reads} 段${topPlatform ? `,主要在${topPlatform[0]}` : ''}`
|
|
719
|
+
: `${span} 天里几乎什么都没做`,
|
|
720
|
+
hint: '用一两句说这周过得怎么样,不要逐条念数字,也不要编没发生的事。',
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* ---------------- 本地书库 ---------------- */
|
|
725
|
+
|
|
726
|
+
bookDir() {
|
|
727
|
+
return path.join(this.dataDir, 'stroll', 'books');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** 扫本地书库;没有就返回空列表,不报错。 */
|
|
731
|
+
listBooks() {
|
|
732
|
+
const dir = this.bookDir();
|
|
733
|
+
let files = [];
|
|
734
|
+
try {
|
|
735
|
+
files = fs.readdirSync(dir).filter((f) => /\.(txt|md|markdown)$/i.test(f));
|
|
736
|
+
} catch {
|
|
737
|
+
return { ok: true, dir, books: [], empty: true };
|
|
738
|
+
}
|
|
739
|
+
const state = this.store.data.books ?? {};
|
|
740
|
+
const books = files.slice(0, 40).map((name) => {
|
|
741
|
+
const prog = state[name] ?? {};
|
|
742
|
+
const total = Number(prog.total ?? 0);
|
|
743
|
+
const offset = Number(prog.offset ?? 0);
|
|
744
|
+
return {
|
|
745
|
+
name,
|
|
746
|
+
total,
|
|
747
|
+
offset,
|
|
748
|
+
percent: total ? Math.min(100, Math.round((offset / total) * 100)) : 0,
|
|
749
|
+
updatedAt: Number(prog.updatedAt ?? 0),
|
|
750
|
+
notes: Array.isArray(prog.notes) ? prog.notes.length : 0,
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
return { ok: true, dir, books, empty: books.length === 0 };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** 手头那本:最近读过的优先,没读过就第一本。 */
|
|
757
|
+
currentBook(list) {
|
|
758
|
+
const state = this.store.data.books ?? {};
|
|
759
|
+
const ranked = list
|
|
760
|
+
.map((b) => ({ ...b, updatedAt: Number(state[b.name]?.updatedAt ?? 0) }))
|
|
761
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
762
|
+
return ranked[0] ?? null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** 面板上的「在读」卡片:只给一本书的信息,不把整本书塞进去。 */
|
|
766
|
+
bookView() {
|
|
767
|
+
const info = this.listBooks();
|
|
768
|
+
const current = this.currentBook(info.books);
|
|
769
|
+
return {
|
|
770
|
+
dir: info.dir,
|
|
771
|
+
empty: info.empty,
|
|
772
|
+
total: info.books.length,
|
|
773
|
+
current: current
|
|
774
|
+
? {
|
|
775
|
+
name: current.name,
|
|
776
|
+
percent: current.percent,
|
|
777
|
+
offset: current.offset,
|
|
778
|
+
total: current.total,
|
|
779
|
+
notes: (this.store.data.books?.[current.name]?.notes ?? []).slice(-2).reverse(),
|
|
780
|
+
}
|
|
781
|
+
: null,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** 读一段,默认 900 字;读不动就明说,不编。 */
|
|
786
|
+
readBook({ name, chars = 900 } = {}) {
|
|
787
|
+
const list = this.listBooks().books;
|
|
788
|
+
if (!list.length) {
|
|
789
|
+
return { ok: false, error: `书库是空的:把 txt 放进 ${this.bookDir()} 再来。`, dir: this.bookDir() };
|
|
790
|
+
}
|
|
791
|
+
const pick = name
|
|
792
|
+
? list.find((b) => b.name === name || b.name.replace(/\.(txt|md|markdown)$/i, '') === name)
|
|
793
|
+
: this.currentBook(list);
|
|
794
|
+
if (!pick) {
|
|
795
|
+
return { ok: false, error: '没找到这本书。', books: list.map((b) => b.name) };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let text = '';
|
|
799
|
+
try {
|
|
800
|
+
text = fs.readFileSync(path.join(this.bookDir(), pick.name), 'utf8');
|
|
801
|
+
} catch (error) {
|
|
802
|
+
return { ok: false, error: `读不了 ${pick.name}:${error.message}` };
|
|
803
|
+
}
|
|
804
|
+
if (text.slice(0, 200000).includes('\uFFFD')) {
|
|
805
|
+
return { ok: false, error: `${pick.name} 可能是 GBK 编码,另存成 UTF-8 再放进书库。` };
|
|
806
|
+
}
|
|
807
|
+
const clean = text.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n');
|
|
808
|
+
if (!clean.trim()) return { ok: false, error: `${pick.name} 是空的。` };
|
|
809
|
+
|
|
810
|
+
const size = clamp(Number(chars) || 900, 200, 2000);
|
|
811
|
+
const start = Math.min(clean.length, Math.max(0, Number(pick.offset) || 0));
|
|
812
|
+
const chunk = clean.slice(start, start + size);
|
|
813
|
+
const next = Math.min(clean.length, start + chunk.length);
|
|
814
|
+
const state = this.store.data.books ?? {};
|
|
815
|
+
state[pick.name] = {
|
|
816
|
+
...(state[pick.name] ?? {}),
|
|
817
|
+
offset: next,
|
|
818
|
+
total: clean.length,
|
|
819
|
+
updatedAt: Date.now(),
|
|
820
|
+
notes: Array.isArray(state[pick.name]?.notes) ? state[pick.name].notes : [],
|
|
821
|
+
};
|
|
822
|
+
this.store.data.books = state;
|
|
823
|
+
this.store.save();
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
ok: true,
|
|
827
|
+
book: pick.name,
|
|
828
|
+
text: chunk,
|
|
829
|
+
offset: next,
|
|
830
|
+
total: clean.length,
|
|
831
|
+
percent: Math.round((next / clean.length) * 100),
|
|
832
|
+
done: next >= clean.length,
|
|
833
|
+
hint: '读完说一句你自己的感受,别复述剧情,别假装读过后面。',
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** 存一句摘抄,跟着这本书走。 */
|
|
838
|
+
noteBook(text) {
|
|
839
|
+
const body = truncate(String(text ?? '').trim(), 120);
|
|
840
|
+
if (!body) return { ok: false, error: '摘抄要有内容。' };
|
|
841
|
+
const list = this.listBooks().books;
|
|
842
|
+
const pick = this.currentBook(list);
|
|
843
|
+
if (!pick) return { ok: false, error: '书库还是空的。' };
|
|
844
|
+
const state = this.store.data.books ?? {};
|
|
845
|
+
const prog = state[pick.name] ?? { offset: 0, total: 0, updatedAt: Date.now(), notes: [] };
|
|
846
|
+
prog.notes = [...(Array.isArray(prog.notes) ? prog.notes : []), { text: body, at: Date.now() }].slice(-20);
|
|
847
|
+
state[pick.name] = prog;
|
|
848
|
+
this.store.data.books = state;
|
|
849
|
+
this.store.save();
|
|
850
|
+
return { ok: true, book: pick.name, note: body };
|
|
851
|
+
}
|
|
852
|
+
|
|
553
853
|
/* ---------------- 正在看 ---------------- */
|
|
554
854
|
|
|
555
855
|
/**
|
|
@@ -1309,6 +1609,7 @@ export class StrollWorld {
|
|
|
1309
1609
|
{ name: 'stroll.interests', description: '兴趣词' },
|
|
1310
1610
|
{ name: 'stroll.last', description: '上次动作' },
|
|
1311
1611
|
{ name: 'stroll.watch', description: '一起看的当前页面' },
|
|
1612
|
+
{ name: 'stroll.books', description: '在读哪本书、读到百分之几' },
|
|
1312
1613
|
],
|
|
1313
1614
|
},
|
|
1314
1615
|
],
|
|
@@ -1493,8 +1794,7 @@ export class StrollWorld {
|
|
|
1493
1794
|
if (method === 'quick_follow') return await this.quickAct('follow', '', String(args[0] ?? ''));
|
|
1494
1795
|
if (method === 'login') return await this.login(String(args[0] ?? 'douyin'));
|
|
1495
1796
|
if (method === 'refreshBili') {
|
|
1496
|
-
|
|
1497
|
-
return { ok: !!got, ...got };
|
|
1797
|
+
return await this.bili.importFromContext(this.douyin.context);
|
|
1498
1798
|
}
|
|
1499
1799
|
return { ok: false, error: `不认识的方法:${method}` };
|
|
1500
1800
|
} catch (error) {
|
|
@@ -1634,6 +1934,9 @@ export class StrollWorld {
|
|
|
1634
1934
|
inboxCount: (this.inboxCache ?? []).length,
|
|
1635
1935
|
todayReads: this.store.readsToday(),
|
|
1636
1936
|
viewed: this.store.viewedList(6),
|
|
1937
|
+
following: this.followFeedView(4),
|
|
1938
|
+
weekly: this.weeklyReport(7),
|
|
1939
|
+
books: this.bookView(),
|
|
1637
1940
|
week: this.weekBars(),
|
|
1638
1941
|
autoSessions: Number(this.store.data.autoSessions[dayKey(new Date(), this.timeZone)] ?? 0),
|
|
1639
1942
|
autoNext: this.autoNextView(),
|
package/webui/client.js
CHANGED
|
@@ -142,6 +142,15 @@ function hhmm(iso) {
|
|
|
142
142
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** 关注流里的时间:今天给几点,更早给月-日。 */
|
|
146
|
+
function whenShort(ts) {
|
|
147
|
+
const d = new Date(ts);
|
|
148
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
149
|
+
const now = new Date();
|
|
150
|
+
if (d.toDateString() === now.toDateString()) return hhmm(ts);
|
|
151
|
+
return `${d.getMonth() + 1}-${d.getDate()}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
145
154
|
function ago(sec) {
|
|
146
155
|
const n = Math.max(0, Number(sec) || 0);
|
|
147
156
|
if (n < 60) return `${Math.floor(n)} 秒前`;
|
|
@@ -410,6 +419,47 @@ export default {
|
|
|
410
419
|
duo.append(browse, watch);
|
|
411
420
|
root.append(duo);
|
|
412
421
|
|
|
422
|
+
/* ---------------- 她的生活:关注流 / 在读 / 这周 ---------------- */
|
|
423
|
+
const lifeSec = el('section', undefined, 'stroll-sec');
|
|
424
|
+
lifeSec.append(sectionHead('她的生活', '关注流 · 在读 · 这周'));
|
|
425
|
+
|
|
426
|
+
const followCard = el('article', undefined, 'stroll-life-card');
|
|
427
|
+
const followHead = el('div', undefined, 'stroll-life-head');
|
|
428
|
+
followHead.append(el('h4', '关注的人在发什么'), el('span', '', 'stroll-token'));
|
|
429
|
+
const followCount = followHead.lastChild;
|
|
430
|
+
const followEmpty = el('p', '还没有关注流;她自己逛的时候会顺手拉一份。', 'stroll-muted');
|
|
431
|
+
const followList = el('div', undefined, 'stroll-life-list');
|
|
432
|
+
followCard.append(followHead, followEmpty, followList);
|
|
433
|
+
|
|
434
|
+
const bookCard = el('article', undefined, 'stroll-life-card');
|
|
435
|
+
const bookHead = el('div', undefined, 'stroll-life-head');
|
|
436
|
+
bookHead.append(el('h4', '在读'), el('span', '', 'stroll-token'));
|
|
437
|
+
const bookPct = bookHead.lastChild;
|
|
438
|
+
const bookTitle = el('p', undefined, 'stroll-life-title');
|
|
439
|
+
const bookBarWrap = el('div', undefined, 'stroll-life-bar');
|
|
440
|
+
const bookBar = el('i');
|
|
441
|
+
bookBarWrap.append(bookBar);
|
|
442
|
+
const bookMeta = el('span', '', 'stroll-token');
|
|
443
|
+
const bookHint = el('p', '把 txt 放进书库,她就会一段一段地读。', 'stroll-muted');
|
|
444
|
+
bookCard.append(bookHead, bookTitle, bookBarWrap, bookMeta, bookHint);
|
|
445
|
+
|
|
446
|
+
const weekCard = el('article', undefined, 'stroll-life-card');
|
|
447
|
+
const weekHead = el('div', undefined, 'stroll-life-head');
|
|
448
|
+
weekHead.append(el('h4', '这周'), el('span', '', 'stroll-token'));
|
|
449
|
+
const weekRange = weekHead.lastChild;
|
|
450
|
+
const weekLine = el('p', undefined, 'stroll-life-title');
|
|
451
|
+
const weekStats = el('div', undefined, 'stroll-life-stats');
|
|
452
|
+
const weekOut = statBlock('出门');
|
|
453
|
+
const weekComment = statBlock('评论');
|
|
454
|
+
const weekRead = statBlock('看书');
|
|
455
|
+
weekStats.append(weekOut.root, weekComment.root, weekRead.root);
|
|
456
|
+
weekCard.append(weekHead, weekLine, weekStats);
|
|
457
|
+
|
|
458
|
+
const lifeGrid = el('div', undefined, 'stroll-life');
|
|
459
|
+
lifeGrid.append(followCard, bookCard, weekCard);
|
|
460
|
+
lifeSec.append(lifeGrid);
|
|
461
|
+
root.append(lifeSec);
|
|
462
|
+
|
|
413
463
|
/* ---------------- 账号 ---------------- */
|
|
414
464
|
const accountSec = el('section', undefined, 'stroll-sec');
|
|
415
465
|
accountSec.append(sectionHead('账号'));
|
|
@@ -421,7 +471,7 @@ export default {
|
|
|
421
471
|
const biliName = el('strong', 'B站');
|
|
422
472
|
const biliSub = el('span', '未登录');
|
|
423
473
|
biliBody.append(biliName, biliSub);
|
|
424
|
-
const biliBtn = button('
|
|
474
|
+
const biliBtn = button('登录 B站', () => login('bilibili'), 'ghost xs');
|
|
425
475
|
biliTile.append(biliAvatar, biliBody, biliBtn);
|
|
426
476
|
|
|
427
477
|
const dyTile = el('div', undefined, 'stroll-account');
|
|
@@ -778,9 +828,59 @@ export default {
|
|
|
778
828
|
liveWatch = watching ? data.watch : null;
|
|
779
829
|
applyPresence(data?.presence);
|
|
780
830
|
|
|
831
|
+
// 她的生活
|
|
832
|
+
const following = data?.following;
|
|
833
|
+
followList.replaceChildren();
|
|
834
|
+
if (following?.items?.length) {
|
|
835
|
+
followEmpty.style.display = 'none';
|
|
836
|
+
followCount.textContent = `${following.total} 条`;
|
|
837
|
+
for (const item of following.items) {
|
|
838
|
+
const isDy = item.platform === 'douyin';
|
|
839
|
+
const row = el('button', undefined, 'stroll-life-row');
|
|
840
|
+
row.type = 'button';
|
|
841
|
+
row.title = item.title || '';
|
|
842
|
+
row.append(el('span', isDy ? '抖' : 'B', `stroll-life-dot ${isDy ? 'dy' : 'bili'}`));
|
|
843
|
+
const body = el('span', undefined, 'stroll-life-body');
|
|
844
|
+
body.append(el('b', cut(item.author || (isDy ? '抖音' : 'B站'), 10)), el('span', cut(item.title || '', 34)));
|
|
845
|
+
row.append(body);
|
|
846
|
+
if (item.at) row.append(el('span', whenShort(item.at), 'stroll-token'));
|
|
847
|
+
row.addEventListener('click', () => openExternal(item.url, cut(item.title || '这一条', 16)));
|
|
848
|
+
followList.append(row);
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
followEmpty.style.display = '';
|
|
852
|
+
followCount.textContent = '';
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const books = data?.books;
|
|
856
|
+
if (books?.current) {
|
|
857
|
+
bookTitle.textContent = String(books.current.name).replace(/\.(txt|md|markdown)$/i, '');
|
|
858
|
+
bookPct.textContent = `${books.current.percent}%`;
|
|
859
|
+
bookBar.style.width = `${books.current.percent}%`;
|
|
860
|
+
const lastNote = books.current.notes?.[0];
|
|
861
|
+
bookMeta.textContent = `${Math.max(1, Math.round(books.current.offset / 1000))}k / ${Math.max(1, Math.round(books.current.total / 1000))}k 字`;
|
|
862
|
+
bookHint.textContent = lastNote ? `摘:「${cut(lastNote.text, 24)}」` : `书库 ${books.total} 本`;
|
|
863
|
+
} else {
|
|
864
|
+
bookTitle.textContent = '书库还是空的';
|
|
865
|
+
bookPct.textContent = '';
|
|
866
|
+
bookBar.style.width = '0%';
|
|
867
|
+
bookMeta.textContent = '';
|
|
868
|
+
bookHint.textContent = books?.dir ? `把 txt 放进 ${books.dir}` : '把 txt 放进书库目录';
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const weekly = data?.weekly;
|
|
872
|
+
if (weekly) {
|
|
873
|
+
weekRange.textContent = weekly.range ? `${weekly.range.from.slice(5)} – ${weekly.range.to.slice(5)}` : '';
|
|
874
|
+
weekLine.textContent = weekly.headline || '';
|
|
875
|
+
weekOut.set(weekly.totalActions ?? 0);
|
|
876
|
+
weekComment.set((weekly.actions?.comment ?? 0) + (weekly.actions?.reply ?? 0));
|
|
877
|
+
weekRead.set(weekly.reads ?? 0);
|
|
878
|
+
}
|
|
879
|
+
|
|
781
880
|
// 账号
|
|
782
881
|
biliName.textContent = bili.loggedIn ? bili.name || '已登录' : 'B站';
|
|
783
|
-
biliSub.textContent = bili.loggedIn ? `UID ${bili.uid || '—'} · 写动作可用` :
|
|
882
|
+
biliSub.textContent = bili.loggedIn ? `UID ${bili.uid || '—'} · 写动作可用` : '未登录 · 能看不能赞';
|
|
883
|
+
biliBtn.textContent = bili.loggedIn ? '重新登录' : '登录 B站';
|
|
784
884
|
dyName.textContent = dy.loggedIn ? '抖音' : '抖音';
|
|
785
885
|
dySub.textContent = dy.loggedIn
|
|
786
886
|
? dy.running
|
|
@@ -1982,20 +2082,6 @@ export default {
|
|
|
1982
2082
|
}
|
|
1983
2083
|
}
|
|
1984
2084
|
|
|
1985
|
-
async function refreshBili() {
|
|
1986
|
-
msgSet('正在从窗口收 B站登录态…');
|
|
1987
|
-
try {
|
|
1988
|
-
const result = await ctx.invoke('refreshBili');
|
|
1989
|
-
msgSet(
|
|
1990
|
-
result?.ok ? `B站登录态已更新:${result.name || result.uid || ''}` : '窗口里还没 B站登录态;先点「登录 B站」扫码。',
|
|
1991
|
-
!result?.ok,
|
|
1992
|
-
);
|
|
1993
|
-
await refresh();
|
|
1994
|
-
} catch (error) {
|
|
1995
|
-
msgSet(error.message || String(error), true);
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
2085
|
refresh();
|
|
2000
2086
|
loadInbox(true);
|
|
2001
2087
|
if (ctx.interval) {
|
package/webui/style.css
CHANGED
|
@@ -1919,3 +1919,114 @@
|
|
|
1919
1919
|
outline: 2px solid color-mix(in srgb, var(--s-accent) 45%, transparent);
|
|
1920
1920
|
outline-offset: 1px;
|
|
1921
1921
|
}
|
|
1922
|
+
|
|
1923
|
+
/* 她的生活:关注流 / 在读 / 这周 */
|
|
1924
|
+
.stroll-life {
|
|
1925
|
+
display: grid;
|
|
1926
|
+
grid-template-columns: repeat(auto-fit, minmax(188px, 1fr));
|
|
1927
|
+
align-items: start;
|
|
1928
|
+
gap: 10px;
|
|
1929
|
+
}
|
|
1930
|
+
.stroll-life-card {
|
|
1931
|
+
display: flex;
|
|
1932
|
+
flex-direction: column;
|
|
1933
|
+
gap: 8px;
|
|
1934
|
+
min-width: 0;
|
|
1935
|
+
padding: 12px 13px;
|
|
1936
|
+
border: 1px solid var(--s-line);
|
|
1937
|
+
border-radius: var(--s-radius);
|
|
1938
|
+
background: var(--s-surface);
|
|
1939
|
+
}
|
|
1940
|
+
.stroll-life-head {
|
|
1941
|
+
display: flex;
|
|
1942
|
+
align-items: baseline;
|
|
1943
|
+
justify-content: space-between;
|
|
1944
|
+
gap: 8px;
|
|
1945
|
+
}
|
|
1946
|
+
.stroll-life-head h4 {
|
|
1947
|
+
margin: 0;
|
|
1948
|
+
font-size: 12px;
|
|
1949
|
+
font-weight: 500;
|
|
1950
|
+
opacity: 0.66;
|
|
1951
|
+
}
|
|
1952
|
+
.stroll-life-title {
|
|
1953
|
+
margin: 0;
|
|
1954
|
+
font-size: 13px;
|
|
1955
|
+
line-height: 1.45;
|
|
1956
|
+
overflow: hidden;
|
|
1957
|
+
display: -webkit-box;
|
|
1958
|
+
-webkit-line-clamp: 2;
|
|
1959
|
+
-webkit-box-orient: vertical;
|
|
1960
|
+
}
|
|
1961
|
+
.stroll-life-list {
|
|
1962
|
+
display: flex;
|
|
1963
|
+
flex-direction: column;
|
|
1964
|
+
gap: 1px;
|
|
1965
|
+
margin: -2px -4px 0;
|
|
1966
|
+
}
|
|
1967
|
+
.stroll-life-row {
|
|
1968
|
+
display: flex;
|
|
1969
|
+
align-items: center;
|
|
1970
|
+
gap: 8px;
|
|
1971
|
+
width: 100%;
|
|
1972
|
+
padding: 4px;
|
|
1973
|
+
border: 0;
|
|
1974
|
+
border-radius: 7px;
|
|
1975
|
+
background: none;
|
|
1976
|
+
color: inherit;
|
|
1977
|
+
font: inherit;
|
|
1978
|
+
text-align: left;
|
|
1979
|
+
cursor: pointer;
|
|
1980
|
+
appearance: none;
|
|
1981
|
+
}
|
|
1982
|
+
.stroll-life-row:hover {
|
|
1983
|
+
background: color-mix(in srgb, var(--s-accent) 7%, transparent);
|
|
1984
|
+
}
|
|
1985
|
+
.stroll-life-dot {
|
|
1986
|
+
flex: none;
|
|
1987
|
+
display: inline-flex;
|
|
1988
|
+
align-items: center;
|
|
1989
|
+
justify-content: center;
|
|
1990
|
+
width: 16px;
|
|
1991
|
+
height: 16px;
|
|
1992
|
+
border-radius: 5px;
|
|
1993
|
+
font-size: 9px;
|
|
1994
|
+
line-height: 1;
|
|
1995
|
+
color: #fff;
|
|
1996
|
+
}
|
|
1997
|
+
.stroll-life-dot.bili { background: #fb7299; }
|
|
1998
|
+
.stroll-life-dot.dy { background: var(--s-douyin); }
|
|
1999
|
+
.stroll-life-body {
|
|
2000
|
+
display: flex;
|
|
2001
|
+
flex: 1;
|
|
2002
|
+
flex-direction: column;
|
|
2003
|
+
gap: 1px;
|
|
2004
|
+
min-width: 0;
|
|
2005
|
+
}
|
|
2006
|
+
.stroll-life-body b,
|
|
2007
|
+
.stroll-life-body span {
|
|
2008
|
+
overflow: hidden;
|
|
2009
|
+
text-overflow: ellipsis;
|
|
2010
|
+
white-space: nowrap;
|
|
2011
|
+
}
|
|
2012
|
+
.stroll-life-body b { font-size: 12px; font-weight: 500; }
|
|
2013
|
+
.stroll-life-body span { font-size: 11px; opacity: 0.6; }
|
|
2014
|
+
.stroll-life-bar {
|
|
2015
|
+
height: 4px;
|
|
2016
|
+
border-radius: 2px;
|
|
2017
|
+
overflow: hidden;
|
|
2018
|
+
background: color-mix(in srgb, var(--s-accent) 14%, transparent);
|
|
2019
|
+
}
|
|
2020
|
+
.stroll-life-bar i {
|
|
2021
|
+
display: block;
|
|
2022
|
+
width: 0;
|
|
2023
|
+
height: 100%;
|
|
2024
|
+
border-radius: 2px;
|
|
2025
|
+
background: var(--s-accent);
|
|
2026
|
+
transition: width 0.3s ease;
|
|
2027
|
+
}
|
|
2028
|
+
.stroll-life-stats {
|
|
2029
|
+
display: flex;
|
|
2030
|
+
gap: 16px;
|
|
2031
|
+
}
|
|
2032
|
+
.stroll-life-stats .stroll-stat strong { font-size: 17px; }
|