diff --git a/WINDOWS-README.txt b/WINDOWS-README.txt index d48a8f5..307af32 100644 --- a/WINDOWS-README.txt +++ b/WINDOWS-README.txt @@ -1,7 +1,7 @@ -抖音账号助手 0.1.8 — Windows 10/11 x64 +抖音账号助手 0.1.9 — Windows 10/11 x64 【安装】 -只需运行 DouyinAccounts-0.1.8-Windows-x64-Setup.exe。 +只需运行 DouyinAccounts-0.1.9-Windows-x64-Setup.exe。 无需安装 Python、Qt、Patchright、Node.js 或另行下载浏览器。 安装程序不会自动启动业务;没有开机自启、自动登录或默认私信。 默认使用随包的 fingerprint-chromium(adryfish 项目)和 Patchright 驱动,不再回退到系统 Chrome。 diff --git a/build_windows.py b/build_windows.py index 3e4f4f5..799acc3 100644 --- a/build_windows.py +++ b/build_windows.py @@ -168,7 +168,7 @@ def main(): break source_hashes = {p.as_posix(): sha256(p) for p in sorted(Path("src").glob("*.py"))} manifest = { - "app_version": "0.1.8", + "app_version": "0.1.9", "python": sys.version, "chrome": browser, "dependencies": versions, @@ -208,7 +208,7 @@ def main(): "onedir 和离线冒烟已完成。安装 Inno Setup 6 后传 --iscc PATH 以生成最终单文件安装包。" ) subprocess.run([str(iscc), str(ROOT / "installer.iss")], check=True) - installer = ROOT / "release/DouyinAccounts-0.1.8-Windows-x64-Setup.exe" + installer = ROOT / "release/DouyinAccounts-0.1.9-Windows-x64-Setup.exe" (installer.parent / "SHA256SUMS.txt").write_text( sha256(installer) + " " + installer.name + "\n", encoding="ascii" ) diff --git a/docs/2026-10-07-20-45-main-account-listener-retry-diagnosis.md b/docs/2026-10-07-20-45-main-account-listener-retry-diagnosis.md new file mode 100644 index 0000000..c48b4f3 --- /dev/null +++ b/docs/2026-10-07-20-45-main-account-listener-retry-diagnosis.md @@ -0,0 +1,42 @@ +# 大号连接核验后整组暂停:监听脚本过期排查 + +- 调查日期:2026-10-07 +- 现象:大号反复显示“正在连接并核验身份 → 连接成功 → 整组暂停,5 秒后复查”;用户观察到浏览器页面可正常使用。 +- 本次范围:检查本地源码、离线测试与可用运行环境;未执行关注、私信、标记已读等业务操作。随后只修改了本地监听初始化的连接就绪等待与错误诊断,未部署到生产机。 + +## 结论 + +无法确认生产实例的前端 SDK 是否过期:用户补充这是部署在另一台机器上的生产实例,当前拿不到日志,也无法访问其运行时。源码审查发现一个与“身份核验成功后随即暂停”吻合的真实竞态:监听安装在注册事件监听器后立即检查 WebSocket 是否为 OPEN;若此时仍为 CONNECTING,即使它很快会打开,也会被当作失败并进入统一的 5 秒重试。已在本地修复这个竞态并增加 readyState 诊断,但这属于有依据的防御性修复,不等于已证明生产故障就是它,也不等于已确认 SDK 过期。 + +## 源码路径核对 + +1. `src/account_engine.py:425-428` 在主循环运行期间每 15 秒调用一次 `session.identity()`。 +2. 异常处理位于 `src/account_engine.py:500-503`:记录“整组暂停,5 秒后复查:<原因>”,卸载监听并暂停 5 秒,然后进入恢复流程。因此身份接口失败、通知安装失败、推送连接断开等异常都可能出现相同的重复状态。 +3. `session.identity()` 通过当前浏览器会话读取抖音个人资料并校验 UID。页面仍可浏览,只能说明页面本身可用,不能证明个人资料接口返回、绑定身份校验或通知连接均正常。 +4. `src/subscribe_notifications.py:27-31` 依赖当前前端运行时的 Webpack 模块特征(通知事件标记与 frame codec),再定位 `NoticeFrontier`。若模块特征变化、实例不存在或连接未就绪,才有理由怀疑监听器与当前前端 SDK 不兼容;连接瞬断也可能产生相似结果,必须结合实际异常区分。 +5. 当前 `src/` 中未找到“连接成功”这一状态文案。现有源码的连接/核验状态与用户看到的文字不完全一致,可能是打包版本、界面标签或运行分支不同,需先确认实际运行版本。 + +## 已做检查与限制 + +- 本地工作区此前干净;本机默认数据目录 `~/.local/share/DouyinAccounts` 不存在,也未发现本地运行中的该账号应用进程,因而没有当前运行实例的活动日志可读。 +- Windows UI 连接中未发现该应用窗口;浏览器运行时只读探测超时,未能验证受影响账号的实际前端模块或通知连接状态。 +- `python3 src/test_subscribe_notifications.py` 离线测试通过;这些测试使用桩/模拟数据,不代表受影响浏览器的实时监听已通过。 +- `python3 src/test_browser_matrix.py` 因环境缺少 `patchright` 无法运行。 +- 项目此前于 2026-09-20 记录过一次实时检查:当时当前页面的 `NoticeFrontier` / WebSocket 与通知分组仍有效;这是历史观测,不足以证明 2026-10-07 的生产实例仍兼容。 + +默认数据目录为 Windows `%LOCALAPPDATA%\DouyinAccounts` 或 Linux `~/.local/share/DouyinAccounts`;活动日志位于数据目录下的 `logs/YYYY-MM-DD.log`,UI 中的“运行状态/活动日志”也可查看同一条异常。 + +## 本地修复与验证(2026-10-07) + +- `src/subscribe_notifications.py`:安装时若现有 `NoticeFrontier` 尚处于 CONNECTING,最多等待 5 秒的 `open` 事件;遇到 `close` 或超时仍失败,并在成功前重新核对连接实例、readyState 与 UID。未改变 SDK 模块匹配标记,也未新建站点连接。 +- `src/account_session.py`:连接仍未就绪时把 `readyState` 纳入异常信息,便于界面状态暴露 `0/2/3` 等具体值。 +- `src/test_subscribe_notifications.py`:Node 桩测试覆盖 CONNECTING→OPEN、关闭失败与监听器清理。 +- 测试:`python3 src/test_subscribe_notifications.py` 通过;`PYTHONPATH=. python3 -m unittest discover -s src -p 'test_accounts.py' -v` 48 项通过;`py_compile` 与 `git diff --check` 通过。`test_browser_matrix.py` 仍因环境缺少 `patchright` 无法运行。 + +## 后续诊断与处理顺序 + +1. 修复已随 0.1.9 安装到 Windows 101 机器;此次只打开了主窗口,没有启动账号 worker 或在真实账号浏览器上验证监听结果。用户无法获取远端日志,因此生产实例的具体根因仍未知。部署记录见 `docs/2026-10-07-22-18-windows-0-1-9-deploy.md`。 +2. 若部署后暂停消失,说明连接就绪竞态是可能原因,但仍不能单凭这一点断言 SDK 已过期。 +3. 若仍暂停,更新后的状态会显示 WebSocket `readyState`;若异常为“通知 SDK 已变化,无法订阅”或“未找到抖音运行时”,才有直接证据更新模块匹配逻辑。 +4. 若错误指向身份接口、资料解析或 UID 不一致,应检查 `get_current_user.py` 与账号绑定;不要先改通知解析器。 +5. 只在本地测试确认后再考虑部署;回归测试不发送真实关注/私信,也不标记已读。 diff --git a/docs/2026-10-07-22-18-windows-0-1-9-deploy.md b/docs/2026-10-07-22-18-windows-0-1-9-deploy.md new file mode 100644 index 0000000..5a6260c --- /dev/null +++ b/docs/2026-10-07-22-18-windows-0-1-9-deploy.md @@ -0,0 +1,41 @@ +# Windows x64 0.1.9 构建与部署记录 + +- 日期:2026-10-07 +- 版本:0.1.9 +- 目的:部署通知 WebSocket 就绪竞态修复,生成并安装 Windows x64 包。 + +## 连接与目标状态 + +- 连接方式:Windows UI MCP 用于查看状态和 GUI 冒烟;SSH 使用本机 SSH 配置完成源码传输、原生构建和安装。 +- 目标:Windows x64。 +- 操作前检查:桌面没有该应用窗口,也没有检测到该应用进程。默认安装目录已有文件,但当时没有运行中的主程序。 +- 安装器按原 AppId 覆盖安装;未删除或清理 `%LOCALAPPDATA%\DouyinAccounts` 用户数据目录,其目录仍存在。 +- 本记录不保存 SSH 地址、账号、密钥、Cookie、UID 或登录目录内容。 + +## 本地验证与传输 + +部署源码在本地先完成: + +- `PYTHONPATH=. python3 -m unittest discover -s src -p 'test_accounts.py' -v`:48 项通过。 +- `python3 src/test_subscribe_notifications.py`:通过,覆盖 CONNECTING→OPEN、关闭失败和监听器清理。 +- Python 编译检查及 `git diff --check`:通过。 +- `test_browser_matrix.py` 未运行:构建环境缺少 `patchright`。 + +仅传输了 33 个构建所需源码/配置文件,不含数据库、浏览器用户目录、Cookie 或运行日志。传输归档 SHA-256: + +`d3030c28ee890eb622837739fc458a70ba9a994159c3ced6498fd99b799d7b2f` + +## Windows 原生构建与安装 + +- 构建在 Windows x64 上运行 `python .\build_windows.py`;Python 3.12.10,使用锁定的 fingerprint-chromium 148.0.7778.215 和 Inno Setup。 +- 构建脚本退出成功,生成的 manifest 标记版本 0.1.9;隔离的打包冒烟检查通过。 +- 安装包:`C:\Users\Public\Documents\DouyinAccountsBuild-20261007-0.1.9\release\DouyinAccounts-0.1.9-Windows-x64-Setup.exe` +- 安装包大小:222,711,880 bytes。 +- 安装包 SHA-256:`e4f4c302fd14a04c854a3bfa03b9ae93eda0f200e7dc79d9a7c5911fa21ea9ab` +- 使用 Inno Setup 静默安装;退出码 0。安装后程序文件位于 `%LOCALAPPDATA%\Programs\DouyinAccounts`;已安装主程序 SHA-256 与构建输出一致:`65e27b1a7e4c3817bce7744f20c0cc0889aaed760cb7e538dc5bd8eedae45b85`。 + +## GUI 冒烟与安全边界 + +- 安装后通过 Windows UI MCP 从开始菜单启动应用,主窗口成功显示。 +- 未登录账号、未恢复业务规则,也未执行关注、私信、标记已读等写操作。GUI 冒烟后未检测到 Chromium/远程调试浏览器进程,未启动账号 worker;应用目前留在主窗口供人工验收。 +- 这验证了安装包能安装、主程序能打开;没有在真实账号上验证通知监听或确认生产故障已消失。监听就绪竞态修复及其限制见 `docs/2026-10-07-20-45-main-account-listener-retry-diagnosis.md`。 diff --git a/installer.iss b/installer.iss index bc0451a..1ee867d 100644 --- a/installer.iss +++ b/installer.iss @@ -2,14 +2,14 @@ [Setup] AppId={{C9C3EE3B-3666-4E41-A52B-BB309838F701} AppName=抖音账号助手 -AppVersion=0.1.8 +AppVersion=0.1.9 DefaultDirName={localappdata}\Programs\DouyinAccounts DefaultGroupName=抖音账号助手 PrivilegesRequired=lowest ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible OutputDir=release -OutputBaseFilename=DouyinAccounts-0.1.8-Windows-x64-Setup +OutputBaseFilename=DouyinAccounts-0.1.9-Windows-x64-Setup Compression=lzma2 SolidCompression=yes WizardStyle=modern diff --git a/src/account_session.py b/src/account_session.py index 690816c..fe9d26f 100644 --- a/src/account_session.py +++ b/src/account_session.py @@ -186,8 +186,15 @@ class Session: ), main_world=True, ) - if not result.get("connected"): - raise SessionError("通知连接未就绪,整组已暂停") + if not isinstance(result, dict) or not result.get("connected"): + ready_state = ( + result.get("readyState", "unknown") + if isinstance(result, dict) + else "unknown" + ) + raise SessionError( + f"通知 WebSocket 未就绪(readyState={ready_state}),整组已暂停" + ) async def uninstall(self): if self.page and not self.page.is_closed(): diff --git a/src/subscribe_notifications.py b/src/subscribe_notifications.py index 6f215d2..492d46b 100644 --- a/src/subscribe_notifications.py +++ b/src/subscribe_notifications.py @@ -16,7 +16,7 @@ from get_current_user import get_user_from_browser NOTICE_EFFECT_GROUPS = {"700", "960", "961"} # 不改写站点 onmessage、不新建连接;退出时仅卸载自己的监听器。 -INSTALL = r"""(() => { +INSTALL = r"""(async () => { if (location.origin !== 'https://www.douyin.com') throw Error('请选择已登录的抖音标签页'); const key = KEY, uid = UID, chunks = window.webpackChunkdouyin_web; if (!chunks) throw Error('未找到抖音运行时'); @@ -58,7 +58,28 @@ INSTALL = r"""(() => { if (state.wake) state.wake(); }; window[key] = state; - return JSON.stringify({connected: f.readyState === f.OPEN}); + // Page rendering may finish before the existing notification socket opens. + const opened = f.readyState === f.OPEN || await new Promise(resolve => { + let settled = false; + const finish = connected => { + if (settled) return; + settled = true; + clearTimeout(timer); + f.removeEventListener('open', onOpen); + f.removeEventListener('close', onClose); + resolve(connected && f.readyState === f.OPEN); + }; + const onOpen = () => finish(true); + const onClose = () => finish(false); + const timer = setTimeout(() => finish(false), 5000); + f.addEventListener('open', onOpen); + f.addEventListener('close', onClose); + if (f.readyState === f.OPEN) finish(true); + }); + const readyState = f.readyState; + const connected = opened && readyState === f.OPEN + && C.frontierInstance === f && String(f._options.deviceID) === uid; + return JSON.stringify({connected, readyState}); })()""" WAIT = r"""(async () => { diff --git a/src/test_accounts.py b/src/test_accounts.py index 5dcd3c6..206c1f3 100644 --- a/src/test_accounts.py +++ b/src/test_accounts.py @@ -729,6 +729,16 @@ class EngineTest(unittest.IsolatedAsyncioTestCase): await action session.evaluate.assert_not_called() + async def test_install_reports_websocket_readiness_state(self): + session = Session(None, "http://127.0.0.1:9222", "101") + session.identity = AsyncMock() + session.uninstall = AsyncMock() + session.json = AsyncMock( + return_value={"connected": False, "readyState": 0} + ) + with self.assertRaisesRegex(SessionError, "readyState=0"): + await session.install() + async def test_name_only_enrollment_waits_then_backfills_and_starts(self): ident = self.engine.store.add("main", "只填名称") session = AsyncMock() diff --git a/src/test_subscribe_notifications.py b/src/test_subscribe_notifications.py index 7c9d9ad..61a9006 100644 --- a/src/test_subscribe_notifications.py +++ b/src/test_subscribe_notifications.py @@ -278,7 +278,7 @@ def test_js_bridge(): const assert = require('node:assert/strict'); const input = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); const listeners = {}; -const f = {_options:{deviceID:'u'}, readyState:1, OPEN:1, +const f = {_options:{deviceID:'u'}, readyState:0, OPEN:1, addEventListener(type, callback) { (listeners[type] ||= new Set()).add(callback); }, removeEventListener(type, callback) { listeners[type].delete(callback); } }; @@ -291,7 +291,15 @@ global.window = {webpackChunkdouyin_web:[]}; window.webpackChunkdouyin_web.push = function(chunk) { chunk[2](r); return Array.prototype.push.call(this, chunk); }; global.location = {origin:'https://www.douyin.com'}; (async () => { - assert.equal(JSON.parse(eval(input.install)).connected, true); + const installing = eval(input.install); + setTimeout(() => { + f.readyState = 1; + for (const callback of listeners.open) callback(); + }, 20); + const installed = JSON.parse(await installing); + assert.equal(installed.connected, true); + assert.equal(installed.readyState, 1); + assert.equal(JSON.parse(await eval(input.wait))[0].kind, 'open'); const send = frame => {for(const callback of listeners.message) callback({data:new TextEncoder().encode(JSON.stringify(frame))});}; send({service:999,payload:[]}); assert.equal(window.probe.queue.length,0); @@ -309,7 +317,19 @@ global.location = {origin:'https://www.douyin.com'}; assert.equal(listeners.message.size,0); assert.equal(listeners.open.size,0); assert.equal(listeners.close.size,0); - assert.equal(f.readyState,1); + f.readyState = 0; + const closing = eval(input.install); + setTimeout(() => { + f.readyState = 3; + for (const callback of listeners.close) callback(); + }, 20); + const disconnected = JSON.parse(await closing); + assert.equal(disconnected.connected, false); + assert.equal(disconnected.readyState, 3); + window.probe.dispose(); + assert.equal(listeners.message.size,0); + assert.equal(listeners.open.size,0); + assert.equal(listeners.close.size,0); C.frontierInstance=null; await assert.rejects(eval(input.wait),/登录失效/); console.log('JS bridge checks passed');