diff --git a/docs/2026-09-20-11-37-follow-protocol.md b/docs/2026-09-20-11-37-follow-protocol.md new file mode 100644 index 0000000..2f95326 --- /dev/null +++ b/docs/2026-09-20-11-37-follow-protocol.md @@ -0,0 +1,42 @@ +# 抖音关注接口协议核查与修复 + +## 调研范围 + +- 连接本机 CDP `http://127.0.0.1:9222`,使用浏览器现有登录态。 +- 从精选页打开一个视频评论区,进入评论用户主页。 +- 仅检查页面、读取关注状态,并在浏览器端拦截关注按钮的 XHR;未让关注请求发到抖音服务器,未改变真实关注状态。 + +## 观察结果 + +1. 评论用户主页的关注按钮仍然是“关注”,页面可正常展示目标用户信息。 +2. 当前页面点击处理生成的接口没有换路径,仍为: + + `POST /aweme/v1/web/commit/follow/user/` + +3. 请求体没有变化: + + - `type=1` + - `user_id=<数字 UID>` + +4. 当前网页请求新增/明确携带了 `channel=channel_pc_web`,同时网页会附带浏览器环境参数(版本、屏幕、网络等)。敏感标识没有写入日志。 +5. `src/account_session.py` 的浏览器关注脚本原先只带 `device_platform` 和 `aid`,缺少 `channel`;`src/follow_user.py` 的独立脚本已经带有该参数。 + +## 修改 + +更新 `src/account_session.py` 中关注请求及发送后回查使用的公共查询前缀: + +```text +?device_platform=webapp&aid=6383&channel=channel_pc_web +``` + +接口路径、请求体、身份校验、关注状态回查和不确定结果不自动重试的逻辑均保持不变。 + +## 验证 + +- `python -m unittest discover -s src -p 'test_follow_user.py'`:4 个测试通过。 +- `src/account_session.py` LSP 诊断:无诊断发现。 +- 本次没有执行真实关注,未用真实关注作为回归测试。 + +## 结论 + +本次不是接口路径或请求体协议变更,而是小号运行时浏览器关注脚本缺少当前网页请求使用的 `channel=channel_pc_web`。独立 `follow_user.py` 已包含该参数;运行时 `Session.follow()` 已补齐,发送后的恢复核查也同步补齐。 diff --git a/docs/2026-09-20-12-01-windows-package-follow-fix.md b/docs/2026-09-20-12-01-windows-package-follow-fix.md new file mode 100644 index 0000000..5fface0 --- /dev/null +++ b/docs/2026-09-20-12-01-windows-package-follow-fix.md @@ -0,0 +1,36 @@ +# Windows 安装包构建记录:关注参数修复 + +## 范围 + +将当前工作区源码(包含 `src/account_session.py` 的关注请求参数修复)在 Windows x64 主机原生构建为安装包。用户本次要求打包,未执行安装、启动业务、登录、关注或私信操作。 + +## 连接与安全边界 + +- Windows 主机:`10.1.1.101` +- Windows MCP:用于查看 Windows 桌面窗口;批量传输和构建使用 SSH fallback。 +- 构建目录:`C:\Users\Rogee\douyin-pc-build-20260920-follow` +- 未传输登录目录、Cookie、数据库或账号数据。 +- 构建前检查:桌面上没有“抖音账号助手”窗口;发现的 `pythonw.exe` 是 `C:\ui-mcp\server.py` 的 MCP 服务,未停止。 + +## 执行 + +1. 上传当前 `src/`、`packaging/`、构建脚本、安装脚本和 requirements 到 Windows 构建目录。 +2. 校验 `src/account_session.py` 本地与远端 SHA-256 一致。 +3. 在 Windows x64 原生执行: + + ```text + cd /d C:\Users\Rogee\douyin-pc-build-20260920-follow && python build_windows.py + ``` + +4. PyInstaller/Inno Setup 构建成功,耗时约 332 秒。 + +## 产物 + +- Windows 路径: + `C:\Users\Rogee\douyin-pc-build-20260920-follow\release\DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +- 本地副本: + `release/DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +- 大小:222,702,482 bytes +- SHA-256:`DC778F5951192BD578D868550ADCCEB7BF09EE19B921124F92575B4CE5BD2D56` + +本次只完成打包,未安装和 GUI 冒烟;安装/人工验收需用户明确要求后执行。 diff --git a/docs/2026-09-20-12-29-local-cache-detail-view-status-color.md b/docs/2026-09-20-12-29-local-cache-detail-view-status-color.md new file mode 100644 index 0000000..81f9b29 --- /dev/null +++ b/docs/2026-09-20-12-29-local-cache-detail-view-status-color.md @@ -0,0 +1,38 @@ +# 历史事件/作品本地缓存展示与账号状态颜色 + +## 问题 + +- 大号完成历史事件同步后,切换账号再切回,历史列表被 UI 清空,需要再次点击同步。 +- 作品列表同样依赖当前详情页内存 payload,切换回来后无法展示;重复请求列表会增加风控压力。 +- 账号左侧状态点大量落入灰色,启动中、暂停、已验证但未加入 active 等状态不易区分。 + +## 现有数据流核查 + +- `AccountStore.cache_notices()` 已在历史同步和实时详情入库时持久化历史事件。 +- `AccountStore.cache_works()` 已在首次同步、强制增量刷新和定时作品刷新时 upsert 作品缓存。 +- `Engine.fetch_history()` / `fetch_works()` 的网络同步完成后均从 SQLite 缓存返回列表。 +- 缺口在 UI:`show_profile()` 切换账号时只清空 `history_payload` 和 `works_payload`,没有重新读取 SQLite;因此已缓存的数据看起来像“未同步”。 + +## 实现 + +1. 新增只读 `detail_cache` 命令,一次读取当前大号的历史事件和作品缓存;不打开浏览器、不发抖音列表请求。 +2. 账号详情切换时自动读取本地缓存;缓存内容或同步状态变化时也重新读取,保证实时事件入库、作品刷新后详情页更新。 +3. 回包同时校验 request ID 和当前选中账号,旧账号的异步回包不会覆盖当前详情。 +4. 快照携带两套缓存的最新更新时间,用于触发详情页刷新;同步状态、覆盖数和规则变化也会触发本地重读。 +5. 状态点颜色调整: + - 红色:失败、错误、暂停、身份未通过; + - 蓝色:正在连接/启动/停止/重连; + - 绿色:运行中或已登录且身份一致; + - 橙色:等待登录; + - 浅灰色:已停止或未启动。 + +历史和作品的网络同步逻辑保持不变,仍由用户主动同步或既有定时刷新完成;本次只避免详情切换触发重复网络列表请求。 + +## 验证 + +- `test_plan01.py`:7 个通过,覆盖缓存读取、更新时间标记和无浏览器 `detail_cache`。 +- `test_follow_user.py`:4 个通过。 +- `test_accounts.py`:43 个通过。 +- 修改文件 Python 编译通过,LSP 错误诊断为 0。 + +本次尚未重新打包或安装到 Windows;需要单独执行 Windows 原生构建和安装验收。 diff --git a/docs/2026-09-20-13-00-windows-install-local-cache.md b/docs/2026-09-20-13-00-windows-install-local-cache.md new file mode 100644 index 0000000..65b7fec --- /dev/null +++ b/docs/2026-09-20-13-00-windows-install-local-cache.md @@ -0,0 +1,58 @@ +# Windows 安装:本地历史/作品缓存与状态点修复 + +## 目标 + +将本地缓存详情读取、缓存更新触发和状态点颜色修复重新打包,并安装到 `10.1.1.101`。 + +## 连接与安装前状态 + +- 通过 Windows MCP 检查到“抖音账号助手”正在运行,详情页显示已有历史/作品同步状态,但历史表格仍显示“尚未同步”,符合本次问题现象。 +- 通过 Windows MCP 激活“关闭”,先停止应用。 +- 安装前发现应用管理的 fingerprint-browser 仍有进程占用旧版 `chrome.dll`;未关闭普通 Chrome,只结束了命令行路径属于 `AppData\Local\Programs\DouyinAccounts\fingerprint-browser` 的应用浏览器进程。 + +## 构建 + +源码上传到 Windows 原生构建目录: + +```text +C:\Users\Rogee\douyin-pc-build-20260920-follow +``` + +执行: + +```text +cd /d C:\Users\Rogee\douyin-pc-build-20260920-follow +python build_windows.py +``` + +构建成功,耗时约 311 秒。 + +安装包: + +```text +C:\Users\Rogee\douyin-pc-build-20260920-follow\release\DouyinAccounts-0.1.8-Windows-x64-Setup.exe +``` + +SHA-256: + +```text +59DF2B47FAB574C8B29E466F2436F2C3A2DFBB0F147DC40B7AD1FAEAC6740E19 +``` + +大小:222,724,301 bytes。 + +## 安装结果 + +- 首次静默安装因旧 fingerprint-browser 的 `chrome.dll` 被占用而回滚。 +- 释放应用专属浏览器进程后重试,静默安装退出码为 `0`。 +- 安装目录: + `C:\Users\Rogee\AppData\Local\Programs\DouyinAccounts` +- 安装后的 `DouyinAccounts.exe` 已更新,卸载程序存在。 +- 安装日志: + `C:\Users\Rogee\douyin-pc-build-20260920-follow\install.log` + +安装没有删除 `%LOCALAPPDATA%\DouyinAccounts`,历史事件、作品缓存和登录目录保留。 + +## 验收边界 + +未执行真实关注、私信、任务启动或规则恢复。安装后通过 SSH 启动的进程属于非交互 Services 会话,未形成可供 Windows MCP 操作的可见主窗口;该隐藏进程已停止。安装文件、退出码和安装目录已核验,交互式 GUI 冒烟需在用户桌面手动启动后继续验收。 diff --git a/docs/2026-09-20-13-35-realtime-notice-effect-group.md b/docs/2026-09-20-13-35-realtime-notice-effect-group.md new file mode 100644 index 0000000..999ca6f --- /dev/null +++ b/docs/2026-09-20-13-35-realtime-notice-effect-group.md @@ -0,0 +1,61 @@ +# 实时事件未触发任务:通知分组协议定位 + +## 现象 + +历史通知接口可以完整返回事件,但大号实时监听没有把事件送入 `inbox`,因此小号没有可领取任务。 + +## 证据 + +### 远程日志 + +旧监听日志出现过: + +- 心跳中的“本连接推送数=0、新增通知数=0”; +- 随后出现“通知连接已断开”,监听进入暂停/重连; +- 历史同步仍能成功保存通知。 + +当前安装后的日志也证明历史路径正常;业务启动需要用户手动执行,程序不会自动恢复业务。 + +### 当前网页运行时 + +通过本机 CDP 只读检查抖音当前运行时: + +- `NoticeFrontier` 模块仍存在; +- Frontier WebSocket 为 OPEN; +- 当前网页仍按 service `20313` 和 `20003` 处理通知; +- 当前网页通知分组映射为: + - `ALL` -> effect group `700`; + - `ALL_WITH_RECOMMEND` -> effect group `960`; + - `RECOMMEND` -> effect group `961`。 + +## 根因 + +`src/subscribe_notifications.py` 的 `notice_ids()` 过去只接受: + +```text +960、961 +``` + +因此新的 `ALL` 实时推送(effect group `700`)虽然可以到达 Frontier,但被本地过滤器丢弃,后续不会写入 `inbox`,也不会触发小号任务。历史接口使用的 `notice_group=700` 已经暴露了这个新分组,但实时解析没有同步更新。 + +WebSocket/Frontier 模块和 service 编号本身没有发现协议断裂,不需要改成定时拉取。 + +## 修复 + +`NOTICE_EFFECT_GROUPS` 现在接受: + +```text +700、960、961 +``` + +保留 `960/961` 兼容旧版本网页分组;不改变通知详情读取、不标记已读、不自动重试任务的安全逻辑。 + +## 验证 + +- `python src/test_subscribe_notifications.py`:通过,包含 service 20313/20003 和 JS bridge 检查。 +- `PYTHONPATH=.:src python src/test_accounts.py`:43 个通过。 +- `python src/test_notifications.py`:通过。 +- `python src/test_activity.py`:通过。 +- Python 编译和 LSP 错误诊断:通过。 + +本次只修复实时事件分组解析,尚未重新打包安装 Windows 版本,也未执行真实关注、私信或标记已读操作。 diff --git a/docs/2026-09-20-13-52-windows-deploy-event-ui-realtime.md b/docs/2026-09-20-13-52-windows-deploy-event-ui-realtime.md new file mode 100644 index 0000000..111c474 --- /dev/null +++ b/docs/2026-09-20-13-52-windows-deploy-event-ui-realtime.md @@ -0,0 +1,31 @@ +# Windows 部署记录:事件列表与实时通知修复 + +- 时间:2026-09-20 13:52 +- 主机:`10.1.1.101` +- 构建目录:`C:\Users\Rogee\douyin-pc-build-20260920-follow` +- 安装目录:`C:\Users\Rogee\AppData\Local\Programs\DouyinAccounts` + +## 变更 + +- 事件列表“来源用户”列缩窄,显示短名称,完整 UID 保留在悬浮提示中。 +- 事件列表支持右键“重新执行任务”,仍经过确认、去重、冷却及入队安全检查。 +- 按账号缓存历史事件和作品数据,切换账号优先恢复本地缓存。 +- 实时通知 effect group 兼容 `700`、`960`、`961`,继续使用 Frontier WebSocket,不增加定时历史拉取。 + +## 本次操作 + +1. 通过 SSH 上传最新 `src/`、构建脚本、安装脚本和依赖文件;远端 `accounts_app.py`、`subscribe_notifications.py` SHA-256 与本地一致。 +2. 在 Windows 原生执行 `python build_windows.py`,构建成功: + `C:\Users\Rogee\douyin-pc-build-20260920-follow\release\DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +3. 首次静默安装返回 `5`,检查发现旧版专属 `fingerprint-browser` 子进程仍在运行;仅停止该安装目录下的受管浏览器进程,未触碰普通 Chrome。 +4. 重新静默安装返回 `0`,安装日志记录 `Installation process succeeded`。 +5. 安装后已确认构建目录与安装目录的 `DouyinAccounts.exe` SHA-256 相同: + `7DC33A9BD74B88362B33C7B447046438CF48ECFFFC374F81C6265996AF813047` +6. 已确认本地数据目录仍存在,包含日志、profiles、作品封面缓存;未删除历史数据。 +7. 通过 Windows UI 启动应用并检查界面:标题为“抖音账号助手 · 自动操作默认关闭”,当前业务未启动;历史事件页显示“不会标记已读,也不会自动创建任务”。 +8. UI 检查确认历史事件列“来源用户”已为窄列布局(相对作品 ID、通知 ID 等列不再撑满窗口)。 + +## 验证边界 + +- 未自动登录、启动业务规则、发送关注/私信或标记已读。 +- 实时事件到达和小号任务触发需由用户手动在已登录状态下验收。 diff --git a/docs/2026-09-20-14-19-auto-start-worker-state.md b/docs/2026-09-20-14-19-auto-start-worker-state.md new file mode 100644 index 0000000..a196691 --- /dev/null +++ b/docs/2026-09-20-14-19-auto-start-worker-state.md @@ -0,0 +1,46 @@ +# 大号登录自动启动与小号任务停止状态 + +- 时间:2026-09-20 14:19 +- 主机:`10.1.1.101` +- 构建目录:`C:\Users\Rogee\douyin-pc-build-20260920-follow` +- 安装目录:`C:\Users\Rogee\AppData\Local\Programs\DouyinAccounts` + +## 问题 + +登录回填后只记录“业务未自动启动”,大号监听和小号 worker 均未进入运行集合,已有任务无法继续领取/执行。 + +## 实现 + +- 大号登录并完成身份核验后,自动启动该组大号监听及所有“已绑定、未手动停止”的小号。 +- 未登录小号不阻塞大号监听;小号登录后若所属大号已在运行,则自动加入该组。 +- SQLite `accounts.task_stopped` 持久化小号手动停止标记。 +- 小号右键菜单增加“任务状态”“启动任务”“停止任务”;当前不可用的相反操作置灰。 +- 手动停止的小号从任务分配、领取和自动启动路径排除;应用重启、大号重新登录及组启动均不会清除标记。 +- 恢复小号任务时清除标记并重新触发待分配任务检查;已有 pending 任务不自动重写结果。 +- 恢复 worker 账号时默认写入手动停止标记,避免恢复动作未经确认直接执行。 +- 界面标题、账号详情和状态色明确显示自动启动及手动停止状态。 + +## 本地验证 + +- `test_accounts.py`:46 项通过。 +- `test_plan01.py`:7 项通过。 +- `test_subscribe_notifications.py`、通知及 activity 检查通过。 +- Python 编译、`git diff --check` 通过。 +- LSP error 级检查无发现;一个 LSP 服务因工具超时未能提供额外确认。 +- 本地 Qt smoke test 未执行成功,原因是当前 Linux 环境缺少 `patchright`;不影响 Windows 原生构建验证。 + +## Windows 部署 + +1. 上传最新 `src/`、`docs/plan01.md`、构建脚本和依赖文件;关键源码 SHA-256 与本地一致。 +2. 在 Windows x64 原生执行 `python build_windows.py`,生成: + `C:\Users\Rogee\douyin-pc-build-20260920-follow\release\DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +3. 静默安装返回 `EXIT=0`。 +4. 构建目录和安装目录 `DouyinAccounts.exe` SHA-256 一致: + `506949F402246E5DF28B0B0CDE7E7CF23D2C0D3E7AF3563DC247981BAC3B9296` +5. 安装后历史数据目录仍存在,未删除登录目录、任务账本或缓存。 +6. 通过 Windows UI 启动,窗口标题为“抖音账号助手 · 大号登录后自动启动可用小号”;账号详情显示任务自动启动状态,业务未被本次验收自动触发。 + +## 验收边界 + +- 未自动登录、未自动启动真实业务进行关注/私信/已读操作。 +- 需用户在已登录环境下触发一次大号登录/刷新,确认可用小号自动进入运行状态;再通过小号右键验证停止、重启应用和重新登录后的持久化行为。 diff --git a/docs/2026-09-20-14-52-dm-send-failure-fix.md b/docs/2026-09-20-14-52-dm-send-failure-fix.md new file mode 100644 index 0000000..d96a36c --- /dev/null +++ b/docs/2026-09-20-14-52-dm-send-failure-fix.md @@ -0,0 +1,40 @@ +# 历史任务私信失败定位与修复 + +- 时间:2026-09-20 14:52 +- 任务:小号“溪溪”向“肉宝儿”发送历史任务私信失败 + +## 定位证据 + +从 Windows 本地 SQLite 只读查询到该小号最近的私信任务: + +- 任务状态:`failed` +- SDK 返回:`success=false` +- `status_code=3` +- `check_code=2` +- 未返回 `server_id` +- 同一目标此前任务曾成功,因此不是 UID、会话目标匹配或消息文本格式的固定错误。 + +通过当前抖音页面的 IM SDK 模块确认: + +- `SendMessageStatus[3]` 为 `CHECK_MSG_NOT_PASS`。 +- 站内发送流程不是直接调用 `sdk.sendMessage`,而是先经过 `sendMessageManager`。 +- 站内 manager 流程会在发送前刷新 `identity_security_token` 等安全请求头,并在消息检查失败时处理官方二次验证回调;原实现绕过了这条流程。 + +## 修复 + +`src/douyin_im.py` 改为: + +1. 通过 `sendMessageManager.createMessageBuilder` 构造文本消息。 +2. 通过 `sendMessageManager.sendMessage` 发送,复用站内发送前安全头和官方失败回调。 +3. 保留一次性官方验证/重试能力,但不在本程序层自动重发未知结果。 +4. 记录状态码及是否存在验证信息,不保存原始 `checkMsg`,避免把二次验证配置写入任务账本/日志。 + +## 验证 + +- `src/test_douyin_im.py`:2 项通过,覆盖 manager builder 发送路径、预览无副作用、登录失效拒绝和超时不重试。 +- `src/test_accounts.py`:46 项通过。 +- `src/test_plan01.py`:7 项通过。 +- 通知/activity 检查通过。 +- Python 编译、`git diff --check`、LSP error 检查通过。 +- 未对“溪溪 → 肉宝儿”执行补发,避免重复发送;修复后的真实发送需由用户手动启动任务验收。 +- 用户随后要求改用本机 CDP;按项目约束未执行真实私信回归,也未安装本次修复到远程 Windows。远程仅完成过构建验证,安装包留在构建目录。 diff --git a/docs/plan01.md b/docs/plan01.md index b14c42b..41cfbc6 100644 --- a/docs/plan01.md +++ b/docs/plan01.md @@ -5,6 +5,8 @@ > 已纳入 [2026-09-08 评审](2026-09-08-15-56-plan01-review.md) 的十项补充。后续 UI、生命周期和任务安全实现以本文及 `AGENTS.md` 为准;早期计划中的恢复顺序、动作串并行和待确认项不再覆盖本文。新增字段、命令与交互仍须实施并验收,不能因写入方案就标记已完成。 > > **用户最新调整**:任务不需要人工核定。`unknown` 仅作结果不确定的只读记录,不提供人工判定成功/失败、提交核定说明或据此修改状态的操作;不自动重发和既有安全保护不变。此决定覆盖原评审的人工核对建议,见 [变更记录](2026-09-08-16-40-unknown-readonly.md)。 +> +> **2026-09-20 用户确认的生命周期调整**:大号成功完成登录和身份核验后,自动启动该组中已绑定且未被手动停止的小号;未登录小号等待登录,不阻塞大号监听。小号右键提供“启动任务 / 停止任务”;手动停止标记写入 SQLite,应用重启和大号再次登录都不得自动恢复,必须由小号右键显式启动。 ## 1. 目标 @@ -16,7 +18,7 @@ - 选中大号后,右侧查看历史事件和作品列表。 - 选中小号后,右侧查看当前任务和历史执行记录。 - 窗口底部持续显示全局运行日志。 -- 不改变已有业务约束:默认不自动执行,登录失效不自动登录,身份不匹配不执行,unknown 结果不自动重发。 +- 不改变已有业务约束:不自动登录,身份不匹配不执行,unknown 结果不自动重发;大号身份核验成功后,仅自动启动已绑定且未手动停止的小号。 ## 2. 总体布局 @@ -116,7 +118,7 @@ | 阶段/状态 | 进入条件与允许行为 | 退出条件与约束 | | --- | --- | --- | | 启动恢复 | 获得本地数据库单实例使用权、完成必要迁移;此时不存在 worker | 遗留 running 先转 unknown,恢复失败则停在错误状态,不开放启动 | -| 已停止 | 可以查看本地数据、编辑规则、显式登录或只读同步 | 手动启动并确认积压任务后进入启动中;恢复选择本身不启动 | +| 已停止 | 可以查看本地数据、编辑规则、显式登录或只读同步 | 大号核验成功后自动启动可用小号;手动停止的小号只能由其右键显式启动;恢复选择本身不启动 | | 启动中 | 建立账号连接,核验实际 UID 和所需能力;禁止小号领取 | 大号就绪后才放行通过核验的小号;失败显示具体原因;停止请求随时有效 | | 监听中/等待任务/执行中 | 大号已就绪,小号身份与归属有效,当前安全限制允许 | 小号每次只领取一个通知批次;批内关注/私信独立并行 | | 已暂停/需要登录/身份异常 | 立即关闭对应领取入口;大号异常影响整组,小号异常只影响自身 | 不自动登录、改派或解除风控;恢复需显式操作及重新核验 | @@ -182,7 +184,7 @@ 说明: -- 小号不提供“启动/停止所属组”;账号组统一由大号节点右键或顶部“启动勾选组 / 停止全部”管理,避免用户误以为可以只启动或停止单个小号。 +- 小号提供“启动任务 / 停止任务”,只控制自身任务领取和执行,不改变所属大号监听;停止标记持久化,应用重启、大号重新登录和组启动都不会自动恢复,菜单与详情明确显示“已手动停止”。 - 小号不显示大号专属的规则配置、历史事件同步和作品监控;执行记录直接在选中小号后的右侧详情区查看。 - “切换 / 解除归属”先停止原所属组(未分配小号无需此步);有 running 或处于启停/删除中时拒绝。确认后在事务中取消旧组 pending 并更新归属,不改派、不重发,保留历史和 unknown;转移后不自动启动。 - “删除小号”必须先停止所属组并等待在途任务结束;关闭浏览器、删除独立 profile 成功后才从活动列表移除。 @@ -203,7 +205,7 @@ UI 禁用仅用于提示,后端仍须按当前状态、角色和内部账号 I | 操作 | 允许条件 | 冲突/异常行为 | | --- | --- | --- | -| 启动组 | 恢复已完成;组非启动中/停止中/删除中;积压确认有效 | 重复请求不创建第二套 worker;逐账号报告就绪或失败 | +| 启动组 | 恢复已完成;组非启动中/停止中/删除中;积压确认有效 | 重复请求不创建第二套 worker;逐账号报告就绪或失败;已手动停止的小号不加入 | | 停止/退出 | 任意运行阶段,包括登录等待和长同步 | 优先关闭领取入口;重复提交返回同一收尾状态,不排在同步后 | | 登录/打开浏览器 | 用户显式发起;身份变更前停止对应组 | 不自动登录;普通只读资料刷新不等于允许换号 | | 历史/作品同步 | 有效大号,当前 UID 核验通过 | 同账号同步合并/拒绝重复请求;不同账号可并发;停止可取消只读任务 | @@ -635,8 +637,9 @@ SQLite 是任务真源,内存队列只负责唤醒和调度。事件写入和 ## 12. 验收清单 - [ ] 添加大号和小号后,账号树层级、归属和状态清晰可见。 -- [ ] 大号复选框可批量启动,应用启动不会自动执行任务。 -- [ ] 右键菜单只显示当前账号角色支持的生命周期、归属和配置操作;小号不能直接启动或停止所属组。 +- [ ] 大号复选框可批量启动;大号登录核验成功后自动启动已绑定且未手动停止的小号。 +- [ ] 右键菜单只显示当前账号角色支持的生命周期、归属和配置操作;小号可启动/停止自身任务但不能启动或停止所属组。 +- [ ] 小号手动停止标记在 SQLite、应用重启和大号再次登录后保持,菜单与详情显示明确状态。 - [ ] 大号右侧只有历史事件和作品列表两个主要 TAB,相关入口不再在右键重复提供。 - [ ] 小号右侧能区分当前任务与历史任务;unknown 只读展示,无人工核定按钮、结论提交或改状态操作。 - [ ] 历史事件同步是只读的,默认不勾选,必须二次确认后才创建任务。 diff --git a/src/account_engine.py b/src/account_engine.py index 5b50ba5..967329b 100644 --- a/src/account_engine.py +++ b/src/account_engine.py @@ -21,7 +21,7 @@ class Engine: self.store.audit = self.audit self.audit.record( "程序启动", - "日志按本地日期保存;UI 最近 2000 条;业务不会自动启动,历史旧日志保留", + "日志按本地日期保存;UI 最近 2000 条;大号核验后自动启动可用小号,历史旧日志保留", ) self.store.recover() self.manager = BrowserManager(root) @@ -95,7 +95,7 @@ class Engine: self.login_tasks[ident] = asyncio.create_task(self.enroll(ident)) async def enroll(self, ident): - """Read-only login observation; never logs in or enables business automatically.""" + """Observe login, then start the main group and eligible workers.""" first = True while True: try: @@ -104,11 +104,25 @@ class Engine: return first = False await self.open(ident) + account = self.store.account(ident) + started = False + if account["role"] == "main": + started = await self.auto_start_group(ident) + elif ( + account["owner"] + and not account["task_stopped"] + and account["owner"] in self.desired + ): + started = await self.auto_start_group(account["owner"]) self.state( ident, - "资料已刷新" - if ident in self.desired - else "登录信息已回填,业务未自动启动", + "资料已刷新,业务已自动启动" + if started + else "资料已刷新;任务已手动停止" + if account["role"] == "worker" and account["task_stopped"] + else "资料已刷新;等待所属大号监听" + if account["role"] == "worker" and account["owner"] + else "登录信息已回填,等待大号登录", ) return except Exception as exc: # noqa: BLE001 - browser boundary @@ -122,6 +136,21 @@ class Engine: ) return + async def auto_start_group(self, main): + try: + await self.start([main], automatic=True) + except (ValueError, RuntimeError) as exc: + self.store.log( + "自动启动失败", + "登录核验已完成,但业务未启动;请检查规则、账本和账号状态后重试", + logging.WARNING, + source=main, + 原因=str(exc), + ) + self.state(main, "登录已完成,自动启动失败:" + str(exc)) + return False + return True + async def stop_login(self, ident): task = self.login_tasks.pop(ident, None) if task and not task.done(): @@ -177,7 +206,23 @@ class Engine: "rules": rules, } - async def start(self, groups): + def _activate(self, ident): + runner = self.runners.get(ident) + active = ident in self.desired and runner is not None and not runner.done() + if not active: + self.run_generation[ident] = self.run_generation.get(ident, 0) + 1 + self.stopping.discard(ident) + self.desired.add(ident) + if runner is None or runner.done(): + generation = self.run_generation[ident] + role = self.store.account(ident)["role"] + self.runners[ident] = asyncio.create_task( + self.main_loop(ident, generation) + if role == "main" + else self.worker_loop(ident, generation) + ) + + async def start(self, groups, automatic=False): groups = list(dict.fromkeys(groups)) if self.ledger_blocked: raise RuntimeError("任务账本异常,恢复存储并重新启动前不会领取任务") @@ -188,48 +233,41 @@ class Engine: raise ValueError("只能按大号账号组启动") if account["delete_state"]: raise ValueError("账号正在删除收尾,不能启动") - ids = { + candidate_ids = [ account["id"] for account in accounts if account["id"] in groups or account["owner"] in groups - } - for ident in ids: + ] + ids = [] + for ident in candidate_ids: account = self.store.account(ident) if account["delete_state"]: raise ValueError("组内存在未完成的删除操作,不能启动") if ident in self.stopping: raise RuntimeError("账号正在停止收尾,完成前不能再次启动") + if account["role"] == "worker" and account["task_stopped"]: + continue if account["uid"] is None: + if automatic and account["role"] == "worker": + continue raise ValueError( "请先在浏览器中完成所选组全部账号的登录,信息自动回填后再启动" ) + ids.append(ident) self.store.set_setting("selected", groups) for ident in ids: - runner = self.runners.get(ident) - active = ident in self.desired and runner is not None and not runner.done() - if not active: - self.run_generation[ident] = self.run_generation.get(ident, 0) + 1 - self.stopping.discard(ident) - self.desired.add(ident) + self._activate(ident) for group in groups: self.store.log( "业务启动", - "用户手动启动账号组;将按已保存的规则处理通知", + "大号登录核验后自动启动监听和可用小号;手动停止的小号保持停止" + if automatic + else "用户手动启动账号组;将按已保存的规则处理通知", source=group, 组内账号数=sum( a["id"] == group or a["owner"] == group for a in accounts ), ) - for ident in ids: - runner = self.runners.get(ident) - if runner is None or runner.done(): - generation = self.run_generation[ident] - role = self.store.account(ident)["role"] - self.runners[ident] = asyncio.create_task( - self.main_loop(ident, generation) - if role == "main" - else self.worker_loop(ident, generation) - ) async def stop(self, ids=None): async with self._stop_lock: @@ -303,6 +341,34 @@ class Engine: ] ) + async def stop_worker(self, ident): + account = self.store.account(ident) + if account["role"] != "worker": + raise ValueError("只能停止小号任务") + self.store.set_worker_stopped(ident, True) + await self.stop([ident]) + self.state(ident, "任务已停止(手动)") + + async def start_worker(self, ident): + account = self.store.account(ident) + if account["role"] != "worker": + raise ValueError("只能启动小号任务") + if not account["owner"]: + raise ValueError("小号尚未归属大号,不能启动任务") + if account["uid"] is None: + raise ValueError("请先完成小号登录,信息自动回填后再启动任务") + self.store.set_worker_stopped(ident, False) + try: + if account["owner"] not in self.desired: + await self.start([account["owner"]], automatic=True) + else: + self._activate(ident) + self.store.dispatch(account["owner"]) + except Exception: + self.store.set_worker_stopped(ident, True) + raise + self.state(ident, "等待大号恢复监听") + async def collect_details(self, ident, session): pending = self.store.due_details(ident) if pending: @@ -500,6 +566,10 @@ class Engine: while self._active(ident, generation): try: account = self.store.account(ident) + if account["task_stopped"]: + self.state(ident, "任务已停止(手动)") + await self.pause(ident, 1) + continue owner = account["owner"] if ( owner not in self.ready @@ -750,6 +820,30 @@ class Engine: "sync": self.store.sync_state(ident, "works"), } + def detail_cache(self, ident): + account = self.store.account(ident) + if account["role"] != "main" or account["uid"] is None: + raise ValueError("请选择已绑定身份的大号") + rule = decode(account["rule"]) + return { + "account": ident, + "history": { + "account": ident, + "items": self.store.cached_notices(ident), + "sync": self.store.sync_state(ident, "notices"), + "baseline": self.store.config_fingerprint(ident), + }, + "works": { + "account": ident, + "items": self.store.cached_works(ident), + "mode": rule["work_mode"], + "selected": rule["work_ids"], + "refresh_interval": rule["works_refresh_interval"], + "refreshed": 0, + "sync": self.store.sync_state(ident, "works"), + }, + } + def save_work_filter(self, ident, mode, ids, refresh_interval): if mode not in ("all", "selected") or not isinstance(ids, list): raise ValueError("作品监控选择无效") @@ -782,6 +876,8 @@ class Engine: "task_query": "查询异常任务", "stop": "停止全部", "stop_group": "停止组", + "worker_start": "启动小号任务", + "worker_stop": "停止小号任务", "open": "登录或刷新资料", "move": "切换归属", "browser_config": "浏览器设置", @@ -791,6 +887,7 @@ class Engine: "restore_worker": "恢复已删除小号", "history_fetch": "获取历史事件", "history_full": "完整补齐历史事件", + "detail_cache": "读取本地历史和作品缓存", "history_enqueue": "确认历史事件操作", "works_open": "打开作品监控", "works_refresh": "强制增量刷新作品", @@ -863,9 +960,15 @@ class Engine: await self.stop() elif name == "stop_group": await self.stop_group(data["id"]) + elif name == "worker_start": + await self.start_worker(data["id"]) + elif name == "worker_stop": + await self.stop_worker(data["id"]) elif name == "open": self.store.account(data["id"]) self.begin_login(data["id"]) + elif name == "detail_cache": + return self.detail_cache(data["id"]) elif name == "history_fetch": return await self._run_readonly( data["id"], self.fetch_history(data["id"], full=False) @@ -1040,9 +1143,15 @@ class Engine: else ("等待首次登录" if account["uid"] is None else "未通过/未核验") ) account["state"] = self.states.get(ident, "未启动") + if account["role"] == "worker" and account["task_stopped"]: + account["state"] = "任务已停止(手动)" + notices_sync = self.store.sync_state(ident, "notices") + notices_sync["cache_updated"] = self.store.notices_updated(ident) + works_sync = self.store.sync_state(ident, "works") + works_sync["cache_updated"] = self.store.works_updated(ident) account["sync"] = { - "notices": self.store.sync_state(ident, "notices"), - "works": self.store.sync_state(ident, "works"), + "notices": notices_sync, + "works": works_sync, } counts = { r["status"]: r["n"] diff --git a/src/account_session.py b/src/account_session.py index d3807b7..690816c 100644 --- a/src/account_session.py +++ b/src/account_session.py @@ -29,7 +29,7 @@ FOLLOW_JS = r"""async p => { try { const get=async path=>{const r=await fetch(path,{credentials:'include',signal:AbortSignal.timeout(15000)}); const v=await r.json(); if(!r.ok||v.status_code!==0) throw Error('READ_FAILED'); return v;}; - const prefix='?device_platform=webapp&aid=6383'; + const prefix='?device_platform=webapp&aid=6383&channel=channel_pc_web'; const self=await get('/aweme/v1/web/user/profile/self/'+prefix); if(String(self.user?.uid)!==p.expected) throw Error('IDENTITY_MISMATCH'); if(p.target===p.expected) throw Error('SELF_TARGET'); @@ -52,7 +52,7 @@ FOLLOW_JS = r"""async p => { if(sent) { try { const get=async path=>{const r=await fetch(path,{credentials:'include',signal:AbortSignal.timeout(10000)});return await r.json();}; - const q='?device_platform=webapp&aid=6383'; + const q='?device_platform=webapp&aid=6383&channel=channel_pc_web'; const me=await get('/aweme/v1/web/user/profile/self/'+q); if(me.status_code===0 && String(me.user?.uid)===p.expected) { const other=await get('/aweme/v1/web/user/profile/other/'+q+'&user_id='+p.target); diff --git a/src/account_store.py b/src/account_store.py index cbe8043..860be41 100644 --- a/src/account_store.py +++ b/src/account_store.py @@ -187,6 +187,7 @@ class Store: name TEXT NOT NULL, uid TEXT UNIQUE, owner TEXT REFERENCES accounts(id), avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '', rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0, + task_stopped INTEGER NOT NULL DEFAULT 0, profile TEXT NOT NULL DEFAULT '{}', deleted INTEGER NOT NULL DEFAULT 0, delete_state TEXT NOT NULL DEFAULT '', @@ -249,6 +250,11 @@ class Store: ) account_columns.add("deleted") with self.transaction(): + if "task_stopped" not in account_columns: + self.db.execute( + "ALTER TABLE accounts ADD COLUMN task_stopped INTEGER NOT NULL DEFAULT 0" + ) + account_columns.add("task_stopped") if "delete_state" not in account_columns: self.db.execute( "ALTER TABLE accounts ADD COLUMN delete_state TEXT NOT NULL DEFAULT ''" @@ -337,6 +343,7 @@ class Store: name TEXT NOT NULL, uid TEXT UNIQUE, owner TEXT REFERENCES accounts(id), avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '', rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0, + task_stopped INTEGER NOT NULL DEFAULT 0, profile TEXT NOT NULL DEFAULT '{}', CHECK(role='worker' OR owner IS NULL))""") self.db.execute("""INSERT INTO accounts_new(id,role,name,uid,owner,avatar,nickname,rule,rr) SELECT id,role,name,uid,owner,avatar,nickname,rule,rr FROM accounts ORDER BY rowid""") @@ -548,7 +555,7 @@ class Store: if owner is not None and self.account(owner)["role"] != "main": raise ValueError("归属必须为大号") self.db.execute( - "UPDATE accounts SET deleted=0,owner=?,delete_kind='',delete_paths='[]',delete_step='',delete_error='' WHERE id=?", + "UPDATE accounts SET deleted=0,owner=?,task_stopped=1,delete_kind='',delete_paths='[]',delete_step='',delete_error='' WHERE id=?", (owner, ident), ) self.log( @@ -559,6 +566,30 @@ class Store: ) return self.account(ident) + def set_worker_stopped(self, ident, stopped): + if type(stopped) is not bool: + raise ValueError("小号任务状态无效") + with self.transaction(): + account = self.account(ident) + if account["role"] != "worker": + raise ValueError("只有小号可设置任务状态") + if account["delete_state"]: + raise ValueError("账号正在删除收尾,不能设置任务状态") + self.db.execute( + "UPDATE accounts SET task_stopped=? WHERE id=?", + (stopped, ident), + ) + self.log( + "小号任务停止" if stopped else "小号任务启动", + "已持久化手动停止标记;不会自动领取新任务" + if stopped + else "已清除手动停止标记;允许登录后或手动启动时恢复任务", + worker=ident, + ) + if not stopped and account["owner"]: + self._dispatch(account["owner"]) + return self.account(ident) + def bind_profile(self, ident, profile): uid = validate_uid(profile.get("uid")) saved = visible(profile) @@ -773,6 +804,12 @@ class Store: ) return result + def notices_updated(self, source): + row = self.db.execute( + "SELECT max(updated) AS updated FROM cached_notices WHERE source=?", (source,) + ).fetchone() + return row["updated"] or 0 + def cached_notice(self, source, nid): row = self.db.execute( "SELECT business FROM cached_notices WHERE source=? AND nid=?", @@ -1053,7 +1090,7 @@ class Store: return workers = list( self.db.execute( - "SELECT id,uid FROM accounts WHERE owner=? AND role='worker' AND uid IS NOT NULL AND deleted=0 ORDER BY rowid", + "SELECT id,uid FROM accounts WHERE owner=? AND role='worker' AND uid IS NOT NULL AND task_stopped=0 AND deleted=0 ORDER BY rowid", (source,), ) ) @@ -1062,7 +1099,7 @@ class Store: if main["uid"] is None else "大号规则已关闭,保留待分配通知" if not decode(main["rule"])["enabled"] - else "没有已绑定且归属本组的小号,保留待分配通知" + else "没有已绑定且未手动停止的归属本组小号,保留待分配通知" if not workers else "" ) @@ -1202,6 +1239,7 @@ class Store: account["role"] != "worker" or account["owner"] is None or account["uid"] is None + or account["task_stopped"] or account["delete_state"] ): return [] diff --git a/src/accounts_app.py b/src/accounts_app.py index 9721816..2381b80 100644 --- a/src/accounts_app.py +++ b/src/accounts_app.py @@ -648,11 +648,14 @@ class Window(QMainWindow): self.latest_requests = {} self.latest_global_requests = {} self.detail_account_id = None + self.detail_cache_marker = None self.history_payload = None self.works_payload = None + self.history_payloads = {} + self.works_payloads = {} self.work_cover_cache = Path(self.backend.root) / "work_covers" self.network = QNetworkAccessManager(self) - self.setWindowTitle("抖音账号助手 · 自动操作默认关闭") + self.setWindowTitle("抖音账号助手 · 大号登录后自动启动可用小号") self.resize(1260, 860) central = QWidget() self.setCentralWidget(central) @@ -787,11 +790,26 @@ class Window(QMainWindow): QTableWidget.SelectionBehavior.SelectRows ) self.history_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.history_table.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeMode.ResizeToContents + history_header = self.history_table.horizontalHeader() + history_header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + for column, width in { + 0: 42, + 1: 145, + 2: 88, + 3: 180, + 5: 150, + 7: 165, + 8: 88, + }.items(): + history_header.resizeSection(column, width) + history_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) + history_header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) + self.history_table.setTextElideMode(Qt.TextElideMode.ElideRight) + self.history_table.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu ) - self.history_table.horizontalHeader().setSectionResizeMode( - 4, QHeaderView.ResizeMode.Stretch + self.history_table.customContextMenuRequested.connect( + self.history_context_menu ) main_history_layout.addWidget(self.history_table, 1) @@ -896,7 +914,7 @@ class Window(QMainWindow): vertical.setSizes([590, 180]) layout.addWidget(vertical, 1) self.table = self.current_task_table - self.statusBar().showMessage("正在初始化;不会自动启动业务") + self.statusBar().showMessage("正在初始化;大号登录后自动启动可用小号") backend.snapshot_ready.connect(self.refresh) backend.response.connect(self.response) backend.logs_ready.connect(self.append_logs) @@ -1233,7 +1251,7 @@ class Window(QMainWindow): ) if rule_text: text += "\n规则快照:" + ";".join(rule_text) - text += "\n\n恢复选择不会启动业务;账号安全限制会在领取前再次校验。" + text += "\n\n手动停止的小号不会加入本次启动;账号安全限制会在领取前再次校验。" box = QMessageBox(self) box.setWindowTitle("启动前确认") box.setText(text) @@ -1255,7 +1273,7 @@ class Window(QMainWindow): def start_group_request(self, groups): self.send("start_preview", {"groups": groups}) - def send(self, name, data=None): + def send(self, name, data=None, busy=True): if not self.backend.isRunning(): QMessageBox.warning( self, "核心未运行", "请退出后重新启动应用,检查运行环境。" @@ -1268,15 +1286,18 @@ class Window(QMainWindow): self.latest_requests[(name, data["id"])] = request_id if name == "start_preview": self.latest_global_requests[name] = request_id - for button in self.buttons: - button.setEnabled(False) - self.stop_button.setEnabled(name != "stop") - self.statusBar().showMessage( - "正在处理;停止会先关闭调度和领取入口,请勿强制退出……" - ) + if busy: + for button in self.buttons: + button.setEnabled(False) + self.stop_button.setEnabled(name != "stop") + self.statusBar().showMessage( + "正在处理;停止会先关闭调度和领取入口,请勿强制退出……" + ) self.backend.post(name, data) def response(self, name, success, message): + if name == "detail_cache": + return for button in self.buttons: button.setEnabled(not self.quitting and name != "startup") self.stop_button.setEnabled(not self.quitting) @@ -1349,7 +1370,16 @@ class Window(QMainWindow): and self.latest_global_requests.get(name) != request_id ): return - if name in ("history_fetch", "history_full"): + if name == "detail_cache": + if account_id != self.detail_account_id: + return + history = payload.get("history") + works = payload.get("works") + if isinstance(history, dict): + self.history_dialog(history) + if isinstance(works, dict): + self.works_dialog(works) + elif name in ("history_fetch", "history_full"): self.history_dialog(payload) elif name in ("works_open", "works_refresh", "works_full"): self.works_dialog(payload) @@ -1399,6 +1429,8 @@ class Window(QMainWindow): items = payload.get("items") or [] labels = NOTICE_KIND_LABELS self.history_payload = payload + if payload.get("account"): + self.history_payloads[payload["account"]] = payload sync = self._sync_text(payload.get("sync"), "历史事件同步") self.history_info.setText( f"{sync} · 当前可选 {len(items)} 条;确认前不创建任务、不标记已读。规则和作品范围基线会在提交前再次校验。" @@ -1423,10 +1455,13 @@ class Window(QMainWindow): check.setData(Qt.ItemDataRole.UserRole + 1, item["kind"]) self.history_table.setItem(row, 0, check) users = [] + user_details = [] for index, uid in enumerate(item.get("actor_uids") or []): actor_names = item.get("actor_names") or [] name = actor_names[index] if index < len(actor_names) else "" - users.append(f"{name or '未知用户'}(UID {uid})") + display_name = name or "未知用户" + users.append(display_name) + user_details.append(f"{display_name}(UID {uid})") values = [ date_text(item.get("create_time")), labels.get(item.get("kind"), "其他互动"), @@ -1439,14 +1474,71 @@ class Window(QMainWindow): ] for column, value in enumerate(values, 1): cell = QTableWidgetItem(str(value)) - cell.setToolTip(str(value)) + cell.setToolTip( + "、".join(user_details) if column == 3 else str(value) + ) self.history_table.setItem(row, column, cell) self.filter_history() + def history_context_menu(self, position): + row = self.history_table.rowAt(position.y()) + if row < 0: + return + item = self.history_table.item(row, 0) + nid = item.data(Qt.ItemDataRole.UserRole) if item else None + if not nid: + return + self.history_table.selectRow(row) + menu = QMenu(self) + action = menu.addAction("重新执行任务") + action.triggered.connect( + lambda _checked=False, nid=nid: self.reexecute_history_event(nid) + ) + menu.exec(self.history_table.viewport().mapToGlobal(position)) + + def reexecute_history_event(self, nid): + payload = self.history_payload + if not payload or payload.get("account") != self.detail_account_id: + QMessageBox.information(self, "提示", "请先加载当前账号的历史事件。") + return + event = next( + (item for item in payload.get("items", []) if item.get("nid") == nid), + None, + ) + if event is None: + QMessageBox.information(self, "提示", "事件已不在当前本地缓存中。") + return + QMessageBox.information( + self, + "重新执行任务预览", + "将重新提交这一条历史事件,按当前规则校验并交给组内小号处理。\n\n" + "可能产生真实关注和私信;已有去重、冷却、占用保护仍然生效,结果不确定不会重发。", + ) + if ( + QMessageBox.question( + self, + "确认重新执行任务", + "确认重新提交这条历史事件?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + == QMessageBox.StandardButton.Yes + ): + self.send( + "history_enqueue", + { + "id": payload["account"], + "ids": [nid], + "baseline": payload.get("baseline"), + }, + ) + def works_dialog(self, payload): items = payload.get("items") or [] existing = set(payload.get("selected") or []) self.works_payload = payload + if payload.get("account"): + self.works_payloads[payload["account"]] = payload self.works_all_mode.setChecked(payload.get("mode") == "all") try: refresh_interval = int(payload.get("refresh_interval", 3600)) @@ -1589,6 +1681,15 @@ class Window(QMainWindow): menu.addAction("配置规则", self.rules) else: menu.addAction("切换 / 解除归属", self.move) + stopped = bool(account.get("task_stopped")) + status_action = menu.addAction( + "任务状态:已手动停止" if stopped else "任务状态:允许自动启动" + ) + status_action.setEnabled(False) + start_action = menu.addAction("启动任务", self.start_selected_worker) + start_action.setEnabled(stopped) + stop_action = menu.addAction("停止任务", self.stop_selected_worker) + stop_action.setEnabled(not stopped) menu.addAction("删除小号", self.delete_worker) menu.addAction("账号浏览器设置", lambda: self.browser_settings(True)) menu.addAction("关闭浏览器", lambda: self.selected_command("close_browser")) @@ -1607,6 +1708,7 @@ class Window(QMainWindow): account = self.selected() if not account: self.detail_account_id = None + self.detail_cache_marker = None self.detail_title.setText("选择账号") self.profile_summary.setText("选择账号可查看自动获取的资料与统计") self.detail_task_summary.setText("当前任务:—") @@ -1619,9 +1721,20 @@ class Window(QMainWindow): self._refresh_detail_exceptions(None) return account_changed = self.detail_account_id != account["id"] + self.detail_account_id = account["id"] if account_changed: - self.history_payload = None - self.works_payload = None + cached_history = ( + self.history_payloads.get(account["id"]) + if account["role"] == "main" + else None + ) + cached_works = ( + self.works_payloads.get(account["id"]) + if account["role"] == "main" + else None + ) + self.history_payload = cached_history + self.works_payload = cached_works self.history_table.setRowCount(0) self.works_table.setRowCount(0) self._clear_layout(self.works_card_grid) @@ -1633,7 +1746,10 @@ class Window(QMainWindow): self.works_info.setText( "作品缓存尚未同步;完整标记与覆盖数量以本地数据库为准。" ) - self.detail_account_id = account["id"] + if cached_history: + self.history_dialog(cached_history) + if cached_works: + self.works_dialog(cached_works) profile = decode(account.get("profile", "{}")) counts = " · ".join( f"{label} {profile.get(key) if profile.get(key) is not None else '—'}" @@ -1667,15 +1783,43 @@ class Window(QMainWindow): self.detail_title.setText( f"{account['name']} · {'大号' if is_main else '小号'}" ) + task_status = ( + "已手动停止,不会自动启动" + if account["role"] == "worker" and account.get("task_stopped") + else "允许自动启动" + if account["role"] == "worker" + else "大号登录后自动启动可用小号" + ) self.profile_summary.setText( f"{account['nickname'] or '等待登录自动获取资料'} · {counts}\n" - f"运行:{account.get('state', '未启动')} · 规则:{'已启用' if decode(account.get('rule', '{}')).get('enabled') else '关闭'} · " + f"运行:{account.get('state', '未启动')} · 任务:{task_status} · 规则:{'已启用' if decode(account.get('rule', '{}')).get('enabled') else '关闭'} · " f"登录:{account.get('login', '待登录/检查')} · 身份:{account.get('identity', '等待首次登录')}\n" f"相关任务 {len(relevant)} 条 · 当前/保护中 {current} 条{deletion}\n" f"{self._sync_text(sync.get('notices'), '历史事件')} · {self._sync_text(sync.get('works'), '作品缓存')}" ) self._refresh_detail_tasks(account) self._refresh_detail_exceptions(account) + notices_sync = sync.get("notices") or {} + works_sync = sync.get("works") or {} + sync_marker = ( + account["id"], + account.get("uid"), + account.get("rule"), + notices_sync.get("cache_updated", 0), + notices_sync.get("status"), + notices_sync.get("coverage"), + notices_sync.get("last_success"), + notices_sync.get("error"), + works_sync.get("cache_updated", 0), + works_sync.get("status"), + works_sync.get("coverage"), + works_sync.get("last_success"), + works_sync.get("error"), + ) + if sync_marker != self.detail_cache_marker: + self.detail_cache_marker = sync_marker + if is_main and account.get("uid"): + self.send("detail_cache", {"id": account["id"]}, busy=False) def selected_command(self, name): account = self.selected() @@ -1691,6 +1835,18 @@ class Window(QMainWindow): if ident: self.send("stop_group", {"id": ident}) + def start_selected_worker(self): + account = self.selected() + if not account or account["role"] != "worker": + return + self.send("worker_start", {"id": account["id"]}) + + def stop_selected_worker(self): + account = self.selected() + if not account or account["role"] != "worker": + return + self.send("worker_stop", {"id": account["id"]}) + def restore_worker(self): deleted = self.data.get("deleted_accounts", []) if not deleted: @@ -1975,14 +2131,23 @@ class Window(QMainWindow): for account in data["accounts"]: identity = account.get("identity", "") state = account.get("state") or "未启动" - if "未通过" in identity or "失败" in state: + login = account.get("login", "") + if account["role"] == "worker" and account.get("task_stopped"): + color = "#98a2b3" + elif "未通过" in identity or any( + marker in state for marker in ("失败", "错误", "暂停") + ): color = "#d4380d" - elif account["id"] in data.get("active", []): + elif state.startswith(("正在连接", "正在启动", "正在停止", "重连中")): + color = "#1677ff" + elif account["id"] in data.get("active", []) or ( + "已登录" in login and "已绑定且一致" in identity + ): color = "#389e0d" - elif "待登录" in account.get("login", ""): + elif "待登录" in login or "等待首次登录" in identity: color = "#d48806" else: - color = "#68707d" + color = "#98a2b3" display[account["id"]] = { "title": account["name"] or "未命名账号", "meta": f"名称:{account['nickname'] or '待登录自动获取'}", diff --git a/src/douyin_im.py b/src/douyin_im.py index 7b0cf34..620a608 100644 --- a/src/douyin_im.py +++ b/src/douyin_im.py @@ -43,7 +43,9 @@ EXPRESSION = r""" } if (!service) fail('IM_SDK_NOT_READY'); const sdk = service.imSdkManager.getImSdkInstance(); - if (!sdk) fail('IM_SDK_NOT_READY'); + const sendManager = service.sendMessageManager; + if (!sdk || !sendManager || typeof sendManager.createMessageBuilder !== 'function' || + typeof sendManager.sendMessage !== 'function') fail('IM_SDK_NOT_READY'); const meta = c => ({id: String(c.id), short_id: String(c.shortId), uid: String(c.toParticipantUserId), type: c.type}); const pack = m => ({server_id: String(m.serverId || ''), client_id: m.clientId || null, @@ -74,13 +76,26 @@ EXPRESSION = r""" messages: result.messages.map(pack), has_more: result.hasMore, next_cursor: result.cursor == null ? null : String(result.cursor)}); } - const message = await sdk.createMessage({conversation, type: 7, - content: JSON.stringify({aweType: 700, type: 0, richTextInfos: [], text: p.text})}); + // 复用站内 sendMessageManager,不直接调用 sdk.sendMessage:站内流程会在发送前 + // 更新 identity security headers,并处理官方一次性风控验证/重试回调。 + const builder = sendManager.createMessageBuilder({ + conversation, messageType: 7, enterMethod: 'douyin_accounts_worker'}); + builder.toUid(p.uid).content(JSON.stringify({ + aweType: 700, type: 0, richTextInfos: [], text: p.text})); + const result = await sendManager.sendMessage(builder); + const message = builder.getMessage(); if (!message || typeof message.sendFunc !== 'function') fail('MESSAGE_BUILD_FAILED'); - const result = await sdk.sendMessage({message}); - // 仅明确的 SDK 成功结果算成功;超时或未知状态不能自动重发。 + let checkInfo = {}; + if (typeof result?.checkMsg === 'string' && result.checkMsg) { + try { checkInfo = JSON.parse(result.checkMsg); } catch (_) { /* fixed fields below */ } + } + // 仅明确的 SDK 成功结果算成功;超时或未知状态不能由本程序自动重发。 + // 不返回原始 checkMsg:它可能包含二次验证配置或其他敏感参数。 return JSON.stringify({action: 'send', success: result?.success === true, status_code: result?.statusCode ?? null, check_code: String(result?.checkCode ?? ''), + check_message_present: Boolean(result?.checkMsg), + check_status_code: checkInfo?.status_code ?? null, + verification_required: Boolean(checkInfo?.decision_conf), conversation: meta(conversation), message: pack(message)}); } catch (error) { const known = ['WRONG_ORIGIN', 'LOGIN_CHECK_FAILED', 'LOGIN_REQUIRED', 'SELF_TARGET', diff --git a/src/subscribe_notifications.py b/src/subscribe_notifications.py index 67b930e..6f215d2 100644 --- a/src/subscribe_notifications.py +++ b/src/subscribe_notifications.py @@ -12,6 +12,9 @@ from datetime import datetime, timezone from cdp_explicit import evaluate_script # pyright: ignore[reportMissingImports] from get_current_user import get_user_from_browser +# 抖音通知分组编号随网页版本变化;当前 ALL/ALL_WITH_RECOMMEND 映射为 700/960。 +NOTICE_EFFECT_GROUPS = {"700", "960", "961"} + # 不改写站点 onmessage、不新建连接;退出时仅卸载自己的监听器。 INSTALL = r"""(() => { if (location.origin !== 'https://www.douyin.com') throw Error('请选择已登录的抖音标签页'); @@ -113,7 +116,7 @@ def notice_ids(event): items = [ n for n in notices - if {str(g) for g in n.get("effect_groups", [])} & {"960", "961"} + if {str(g) for g in n.get("effect_groups", [])} & NOTICE_EFFECT_GROUPS ] elif event["service"] == 20003 and payload.get("notice_type") in ( 45, diff --git a/src/test_accounts.py b/src/test_accounts.py index fc8780f..f1be543 100644 --- a/src/test_accounts.py +++ b/src/test_accounts.py @@ -143,6 +143,17 @@ class StoreTest(unittest.TestCase): self.store.dispatch(self.main) self.assertEqual(len(self.store.tasks()), 2) + def test_stopped_worker_is_persistent_and_excluded_from_dispatch(self): + self.store.set_worker_stopped(self.worker, True) + self.store.ingest(self.main, notice("101")) + self.assertEqual(self.store.tasks(), []) + self.assertTrue(self.store.account(self.worker)["task_stopped"]) + self.store.close() + self.store = Store(self.temp.name) + self.assertTrue(self.store.account(self.worker)["task_stopped"]) + self.store.set_worker_stopped(self.worker, False) + self.assertEqual(len(self.store.tasks()), 2) + def test_upgrade_preserves_identity_tasks_and_creates_backup(self): self.store.ingest(self.main, notice("101")) expected = self.store.tasks() @@ -699,7 +710,7 @@ class EngineTest(unittest.IsolatedAsyncioTestCase): await action session.evaluate.assert_not_called() - async def test_name_only_enrollment_waits_then_backfills_without_starting(self): + async def test_name_only_enrollment_waits_then_backfills_and_starts(self): ident = self.engine.store.add("main", "只填名称") session = AsyncMock() session.endpoint = "http://127.0.0.1:9555" @@ -718,7 +729,7 @@ class EngineTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(saved["uid"], "301") self.assertEqual(json.loads(saved["profile"])["follower_count"], 42) self.assertEqual(session.uid, "301") - self.assertEqual(self.engine.desired, set()) + self.assertIn(ident, self.engine.desired) self.assertEqual(self.engine.store.tasks(), []) session.im.assert_not_called() session.follow.assert_not_called() @@ -733,6 +744,26 @@ class EngineTest(unittest.IsolatedAsyncioTestCase): await self.engine.start([ident]) self.assertEqual(self.engine.desired, set()) + async def test_auto_start_skips_unbound_and_stopped_workers(self): + unbound = self.engine.store.add("worker", "未登录", owner=self.main) + stopped = self.engine.store.add("worker", "手动停止", "202", self.main) + self.engine.store.set_worker_stopped(stopped, True) + await self.engine.start([self.main], automatic=True) + self.assertIn(self.main, self.engine.desired) + self.assertIn(self.worker, self.engine.desired) + self.assertNotIn(unbound, self.engine.desired) + self.assertNotIn(stopped, self.engine.desired) + + async def test_worker_start_stop_commands_persist_and_control_runtime(self): + self.engine.desired.add(self.main) + await self.engine.command("worker_stop", {"id": self.worker}) + self.assertTrue(self.engine.store.account(self.worker)["task_stopped"]) + self.assertNotIn(self.worker, self.engine.desired) + with patch.object(self.engine, "_activate") as activate: + await self.engine.command("worker_start", {"id": self.worker}) + self.assertFalse(self.engine.store.account(self.worker)["task_stopped"]) + activate.assert_called_once_with(self.worker) + async def test_im_result_classification_keeps_business_data_and_hides_credentials( self, ): diff --git a/src/test_douyin_im.py b/src/test_douyin_im.py index 8b1d26e..c0a22c4 100644 --- a/src/test_douyin_im.py +++ b/src/test_douyin_im.py @@ -31,7 +31,27 @@ const sdk = { sent++; message.serverId='42'; return {success:true,statusCode:0,checkCode:0}; } }; -const service = {imSdkManager:{getImSdkInstance:()=>sdk}}; +const sendMessageManager = { + createMessageBuilder: ({conversation: current, messageType}) => { + assert.equal(current, conversation); + assert.equal(messageType, 7); + let content = '', message; + const builder = { + toUid: uid => { assert.equal(uid, '12345'); return builder; }, + content: value => { content = value; return builder; }, + getMessage: () => message, + buildMessage: async () => { + message = await sdk.createMessage({conversation: current, type: messageType, content}); + } + }; + return builder; + }, + sendMessage: async builder => { + await builder.buildMessage(); + return sdk.sendMessage({message: builder.getMessage()}); + } +}; +const service = {imSdkManager:{getImSdkInstance:()=>sdk}, sendMessageManager}; const req = {m:{1:'getOrCreatePrivateConversationByUid'}, c:{1:{exports:{A:{instance:{imSdkService:service}}}}}}; const chunks = []; diff --git a/src/test_plan01.py b/src/test_plan01.py index 1d49685..82b2770 100644 --- a/src/test_plan01.py +++ b/src/test_plan01.py @@ -75,6 +75,22 @@ class Plan01StoreTest(unittest.TestCase): ) self.assertNotEqual(before, self.store.config_fingerprint(self.main)) + def test_history_and_works_cache_have_local_update_markers(self): + notice = { + "user_id": "1001", + "nid_str": "12345", + "follow": {"from_user": [{"uid": "3001"}]}, + } + self.store.cache_notices(self.main, [notice], "history") + self.store.cache_works( + self.main, + [{"aweme_id": "4001", "desc": "cached", "create_time": 1}], + ) + self.assertEqual(self.store.cached_notices(self.main)[0]["nid"], "12345") + self.assertEqual(self.store.cached_works(self.main)[0]["aweme_id"], "4001") + self.assertGreater(self.store.notices_updated(self.main), 0) + self.assertGreater(self.store.works_updated(self.main), 0) + class Plan01EngineTest(unittest.TestCase): def test_start_preview_and_unknown_query_are_read_only(self): @@ -129,6 +145,39 @@ class Plan01EngineTest(unittest.TestCase): asyncio.run(check()) temp.cleanup() + def test_detail_cache_reads_history_and_works_without_browser(self): + temp = tempfile.TemporaryDirectory() + engine = Engine(temp.name, None) + main = engine.store.add("main", "主账号", "1001") + engine.store.cache_notices( + main, + [ + { + "user_id": "1001", + "nid_str": "12345", + "follow": {"from_user": [{"uid": "3001"}]}, + } + ], + "history", + ) + engine.store.cache_works( + main, + [{"aweme_id": "4001", "desc": "cached", "create_time": 1}], + ) + + async def check(): + payload = await engine.command("detail_cache", {"id": main}) + self.assertEqual(payload["history"]["items"][0]["nid"], "12345") + self.assertEqual(payload["works"]["items"][0]["aweme_id"], "4001") + snapshot = engine.snapshot() + sync = snapshot["accounts"][0]["sync"] + self.assertGreater(sync["notices"]["cache_updated"], 0) + self.assertGreater(sync["works"]["cache_updated"], 0) + await engine.shutdown() + + asyncio.run(check()) + temp.cleanup() + if __name__ == "__main__": unittest.main() diff --git a/src/test_subscribe_notifications.py b/src/test_subscribe_notifications.py index 0777902..7c9d9ad 100644 --- a/src/test_subscribe_notifications.py +++ b/src/test_subscribe_notifications.py @@ -30,6 +30,7 @@ def test_ids(): item = {"notice_id_str": nid, "effect_groups": [960]} assert sub.notice_ids(push(notices=[item, item])) == [nid] assert sub.notice_ids(push(notices=[{**item, "effect_groups": ["961"]}])) == [nid] + assert sub.notice_ids(push(notices=[{**item, "effect_groups": [700]}])) == [nid] assert sub.notice_ids(push(notices=[{**item, "effect_groups": [100]}])) == [] assert sub.notice_ids(push(20003, notice_type=45, notice_id_str=nid)) == [nid] assert sub.notice_ids(push(20003, notice_type=999, notice_id_str=nid)) == []