Fix task lifecycle and IM message delivery
This commit is contained in:
@@ -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()` 已补齐,发送后的恢复核查也同步补齐。
|
||||
@@ -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 冒烟;安装/人工验收需用户明确要求后执行。
|
||||
@@ -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 原生构建和安装验收。
|
||||
@@ -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 冒烟需在用户桌面手动启动后继续验收。
|
||||
@@ -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 版本,也未执行真实关注、私信或标记已读操作。
|
||||
@@ -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 等列不再撑满窗口)。
|
||||
|
||||
## 验证边界
|
||||
|
||||
- 未自动登录、启动业务规则、发送关注/私信或标记已读。
|
||||
- 实时事件到达和小号任务触发需由用户手动在已登录状态下验收。
|
||||
@@ -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 启动,窗口标题为“抖音账号助手 · 大号登录后自动启动可用小号”;账号详情显示任务自动启动状态,业务未被本次验收自动触发。
|
||||
|
||||
## 验收边界
|
||||
|
||||
- 未自动登录、未自动启动真实业务进行关注/私信/已读操作。
|
||||
- 需用户在已登录环境下触发一次大号登录/刷新,确认可用小号自动进入运行状态;再通过小号右键验证停止、重启应用和重新登录后的持久化行为。
|
||||
@@ -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。远程仅完成过构建验证,安装包留在构建目录。
|
||||
+9
-6
@@ -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 只读展示,无人工核定按钮、结论提交或改状态操作。
|
||||
- [ ] 历史事件同步是只读的,默认不勾选,必须二次确认后才创建任务。
|
||||
|
||||
+137
-28
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
+41
-3
@@ -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 []
|
||||
|
||||
+190
-25
@@ -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 '待登录自动获取'}",
|
||||
|
||||
+20
-5
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+33
-2
@@ -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,
|
||||
):
|
||||
|
||||
+21
-1
@@ -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 = [];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)) == []
|
||||
|
||||
Reference in New Issue
Block a user