# 架构 ## 一句话 **内容模块**用浏览器管家主动刷视频,**社交模块**用 douyin-im 纯 Node 被动收发消息。 两者共用一份 `config.json`,但**运行时不互相依赖**。 ## 总览 ```mermaid flowchart LR subgraph HOST["DSH 宿主进程"] SB["session-bridge.mjs
HTTP 127.0.0.1:3099"] POOL[("DSH 会话池
一人一会话 / 一群一会话")] UI["设置面板
/douyin/api/*"] CFG[("config.json")] SB --> POOL UI --> CFG end subgraph CONTENT["内容模块(主动)"] IDX["index.mjs
douyin_* 工具"] GUARD["content-guard-loop.ps1
限额 / 时段 / 让路"] end subgraph SOCIAL["社交模块(被动)"] BR["social/bridge.mjs
douyin-im WSS"] WD["watchdog.ps1"] end IDX -- "RPC 9443" --> STAR["star-browser 管家"] STAR -- "CDP 9227" --> EDGE["Edge
抖音网页"] GUARD -- "按 profile 精确关进程" --> EDGE BR -- "POST /chat" --> SB SB -- "整段回复" --> BR BR <-- "WSS" --> DY["抖音"] BR -. "写 interrupt.flag" .-> GUARD WD -. "进程守护" .-> BR ``` ## 模块边界(三条隔离) 社交模块不碰浏览器,靠三层保证: | 层 | 做法 | |---|---| | **入口隔离** | 内容模块的入口是 `douyin_*` 工具(走 9443 RPC);社交模块的入口是 douyin-im 的 WSS,`features.chat` 关掉后浏览器侧的私信工具根本调不动 | | **依赖隔离** | `social/bridge.mjs` 只 import `node:fs/path/net` 和 `douyin-im`,不碰 9443 / 9227 / 任何 CDP | | **可观测隔离** | 社交链路跑全程,`msedge` 进程 PID 集合不变 | 另有一条提示词约束:`workspace/AGENTS.md` 禁止会话调用任何工具,防止模型自己去打开浏览器。 ## 内容模块:一次刷视频的生命周期 ```mermaid stateDiagram-v2 [*] --> 待机 待机 --> 判断额度: 产生刷视频欲望(自主) 判断额度 --> 待机: 已达上限 / 不在时段 判断额度 --> 刷视频: 拉起 Edge + 切推荐流 刷视频 --> 刷视频: douyin_next(累计时长) 刷视频 --> 关页面: ① 欲望消失(douyin_stop) 刷视频 --> 关页面: ② 到达每日上限 刷视频 --> 关页面: ③ 出时段边界 刷视频 --> 关页面: ④ 收到消息/任务(让路) 关页面 --> 待机 ``` 四个关闭源都走 `content-guard-loop.ps1`,日志里 `reason=` 分别是 `done` / `cap` / `window` / `interrupt`。 ## 社交模块:一条消息的旅程 ```mermaid sequenceDiagram participant U as 抖音用户 participant IM as douyin-im participant BR as bridge.mjs participant SB as session-bridge :3099 participant AG as DSH 会话 U->>IM: 私信 / 群消息 IM->>BR: message 事件 BR->>BR: 白名单 / 通道 / 触发方式校验 BR->>BR: 写 interrupt.flag(内容模块让路) BR->>SB: POST /chat {key, text} SB->>AG: followup(text) AG-->>SB: turn/end(整段文本) SB-->>BR: {ok, reply, sessionId} BR->>IM: event.reply(分段) IM->>U: 送达(有 serverMessageId) ``` ### 会话为什么不会每次新建 `session-bridge` 建会话的顺序是 **先 resume 再 create**: ```js try { handle = await ctx.agents.resume({ resumeSessionId: sessionId, ... }) } catch { handle = await ctx.agents.create({ sessionId, ... }) } ``` `key` → `sessionId` 的映射落盘在 `social/sessions.json`,所以桥重启、宿主重启都接回同一条会话。 ### 为什么不会出现"打字机碎片" 桥只监听 `assistant/message`(一条完成的消息)和 `turn/end`, **不监听 `assistant/chunk`**,所以吐给抖音的永远是整轮跑完之后的一段完整文本。 ## 数据文件 | 文件 | 谁写 | 作用 | |---|---|---| | `config.json` | 设置面板 | 全部配置;保存即时生效 | | `state.json` | 内容模块 | 今日计数(浏览秒数 / 赞 / 藏 / 评 / 转发 / 回复),0 点重置 | | `browse-log.jsonl` | `douyin_next` | 每条视频一行(文案 / 点赞数 / 时间),日报的素材 | | `reports/douyin-diary-*.txt` | `daily-report.mjs` | 日记体日报 | | `social/channels.json` | `bridge.mjs` | 群 / 好友清单(白名单勾选的数据源) | | `social/sessions.json` | `session-bridge.mjs` | key → sessionId 映射 | | `social/outbox.json` | `douyin_share` 等 | 出站投递(主动发卡片/文字) | | `social/bridge.log` | `bridge.mjs` | 消息收发日志(含 serverMessageId) | | `social/content-guard.log` | 守卫 | `CLOSED reason=...` 记录 | | `data/` | douyin-im | 登录态(Cookie / 设备身份),**不要提交** | ## 几个关键实现点 - **表情**:抖音自带表情是"文本式内联渲染",发 `[呲牙]` 这几个字对方就渲染成图;收藏表情是图片式,走 `segment.sticker()`。 - **视频卡片**:`segment.share({ itemId })`,`itemId` 就是 aweme_id。 - **取 aweme_id**:从接口日志里带 `group_id=` 的请求抠出来,再用页面 DOM 反查校验(命中次数 > 0 才算数)。 - **评论**:只有顶层评论;真 @ 和楼中楼未实现。 ## 已知限制 1. 抖音网页端发评论可能弹短信验证(平台风控),需在浏览器手动过一次验证 2. 评论里真 @ 某人做不到(要操作下拉选人) 3. `social/*.ps1`(守卫 / 看门狗 / 定时)是 Windows PowerShell;非 Windows 请用 cron / systemd 替代,或只跑社交模块 4. `douyin-im` 是第三方库,其登录链与部分接口尚未经大规模真实账号验收