diff --git a/WINDOWS-README.txt b/WINDOWS-README.txt index c225ada..d48a8f5 100644 --- a/WINDOWS-README.txt +++ b/WINDOWS-README.txt @@ -1,7 +1,7 @@ -抖音账号助手 0.1.7 — Windows 10/11 x64 +抖音账号助手 0.1.8 — Windows 10/11 x64 【安装】 -只需运行 DouyinAccounts-0.1.7-Windows-x64-Setup.exe。 +只需运行 DouyinAccounts-0.1.8-Windows-x64-Setup.exe。 无需安装 Python、Qt、Patchright、Node.js 或另行下载浏览器。 安装程序不会自动启动业务;没有开机自启、自动登录或默认私信。 默认使用随包的 fingerprint-chromium(adryfish 项目)和 Patchright 驱动,不再回退到系统 Chrome。 @@ -17,7 +17,7 @@ 若关闭了浏览器,可点击“登录 / 刷新信息”继续;已绑定账号点击此按钮可刷新资料和统计。 只保留一个抖音首页或自己的页面;多个匹配标签会拒绝执行,防止选错。 3. 选择大号,配置规则。默认关闭;可配置通知类别、关注、私信、正文、不同任务批次间隔和同组 UID 冷却。 - 作品监控默认“全部作品”。点击“获取近期作品”或“获取全部历史作品”,可切换为只监控勾选作品。 + 作品监控默认“全部作品”。点击“作品监控”管理本地作品列表、指定作品范围和自动刷新间隔;默认 60 分钟,设为 0 可关闭。 冷却默认 240 分钟(4 小时),设为 0 可关闭;勾选大号后点击启动。规则未开启不会产生或执行业务任务。 4. 每条匹配通知只给一个关联小号,按组内轮询,不跨组操作。 一条聚合通知可能包含多个来源用户,每个有效来源用户各执行配置动作。 @@ -38,23 +38,24 @@ 此操作不删除账号定义、任务和历史,也不影响其他账号。 更换 Chrome 可执行路径会使用不同登录目录,不复制 Cookie。 -【历史事件批量操作(0.1.7 新增)】 -选中已绑定身份的大号,点击“历史事件操作”。程序通过网页原生只读接口分页获取平台当前可返回的全部互动通知,固定 is_mark_read=0,不会自动标记已读。 -获取完成后显示时间、类型、来源 UID/昵称、作品 ID/描述、评论内容、通知 ID和完整业务数据;此时尚未创建任务。 +【历史事件缓存与批量操作(0.1.8 调整)】 +选中已绑定身份的大号,点击“历史事件操作”。首次使用会通过网页原生只读接口分页建立本地缓存,以后只增量同步新事件;固定 is_mark_read=0,不会自动标记已读。 +历史事件和实时事件都会持久保存到 SQLite,重启软件后仍可查看。列表按当前作品范围和所选事件类型过滤,显示正常日期、中文类型、来源用户、作品、评论和通知 ID,不显示数字时间戳或原始 JSON;此时尚未创建任务。 可逐条勾选、全选或取消。点击确认后还会出现第二次真实操作警告;只有再次确认才按当前规则、作品范围、冷却和自操作保护创建任务。 历史任务优先级低于新消息:Worker 领取任务时先取新消息;同 UID 新消息会取消尚未执行的历史任务。已经执行、正在执行或待核对的历史任务不会被静默改写或重发。 同一通知如果先由历史列表入队、随后实时推送到达,尚未执行的任务会提升为新消息优先级,不重复创建。 重复历史通知、规则未开启、通知类别未勾选、作品范围外、无来源用户、冷却中或目标是执行号自身时都可能显示“选择成功但任务数较少”,具体原因查看运行记录。 -【指定作品监控(0.1.7 新增)】 -选中已绑定身份的大号,可点击“获取近期作品”(一页)或“获取全部历史作品”(分页至平台返回结束)。列表显示发布时间、完整描述、作品 ID、统计、封面 URL和完整业务数据。 +【作品缓存与指定作品监控(0.1.8 调整)】 +选中已绑定身份的大号,点击“作品监控”。首次使用会分页建立全部历史作品缓存;以后手动或定时刷新只读取新作品并合并,重启软件后缓存仍在。 +每个大号可单独设置运行期间自动刷新间隔,默认 60 分钟,0 表示关闭;账号组停止后不会自动请求,也可随时点击“强制增量刷新”。列表使用正常日期和独立的点赞、评论、收藏、分享字段,不显示原始 JSON。 默认“监控全部作品”,保持升级前行为并自动包含以后发布的新作品。取消该选项后进入“指定作品”模式,至少勾选一个作品;此后新作品不会自动加入。 指定作品模式按通知内的作品 ID统一过滤点赞、评论、收藏或其他作品互动。平台当前没有暴露稳定的独立收藏通知常量,因此不会硬编码未经证实的类型;只要通知携带作品 ID,就使用同一筛选规则。 -关注通知没有作品 ID,不受作品筛选。近期列表不会丢弃未显示的既有历史选择;需要统一删除旧选择时使用“获取全部历史作品”。 +关注通知没有作品 ID,不受作品筛选。作品列表来自完整本地缓存,因此既有历史选择可以统一管理。 作品选择持久化在大号规则中,新通知和用户确认的历史事件使用同一范围。已入队任务继续使用原规则快照,不因修改作品范围而静默改写。 -【业务输出与认证凭据(0.1.7 调整)】 -UID、通知 ID、昵称、作品 ID/描述、评论内容、私信正文、URL、平台资料和结构化业务结果均按原文显示和写入本地日志,不再使用哈希、尾号或业务字段隐藏。 +【业务输出与认证凭据(0.1.8 调整)】 +普通界面显示可直接阅读的 UID、通知 ID、昵称、作品 ID/描述、评论内容、私信正文、URL、平台资料和业务结果摘要,不显示原始 JSON 或数字时间戳。完整平台业务字段保存在本地 SQLite 中,供问题定位。 Cookie、Authorization、Session、Token、密码、verifyFp、a_bogus、X-Bogus及其他签名/认证字段仍强制隐藏,避免按日日志成为可直接接管账号的凭据文件。 完整业务输出可能包含个人信息并显著增加日志体积。日志仅保存在本机;请限制数据目录访问并自行确定保留期限。 @@ -91,7 +92,7 @@ Cookie、Authorization、Session、Token、密码、verifyFp、a_bogus、X-Bogus 卸载/覆盖升级默认保留账号数据。从 0.1.0 升级旧数据库时会先备份再迁移,保留原 UID、归属、任务和历史。 升级 0.1.2 前请先停止业务并关闭旧 Chrome。新内核使用独立登录目录,需要自行重新登录原账号。 旧目录及旧浏览器配置保留,但新版本不自动沿用旧 Chrome 的路径配置;如需自定义请重新设置指纹浏览器路径。 -0.1.3 及更新版本首次启动旧库时会增加通知重试字段;0.1.5 增加删除标记字段;0.1.6 增加组内 UID 冷却表;0.1.7 增加事件来源和任务优先级字段,并在大号规则增加作品范围。旧账号、通知 ID、任务和登录目录不变。 +0.1.3 及更新版本首次启动旧库时会增加通知重试字段;0.1.5 增加删除标记字段;0.1.6 增加组内 UID 冷却表;0.1.7 增加事件来源、任务优先级和作品范围;0.1.8 增加作品与事件缓存表和作品刷新间隔。旧账号、通知 ID、任务和登录目录不变。 0.1.6 将旧规则的“必须关注成功后再私信”自动关闭;旧版已排队且带前置依赖的任务仍按原快照安全处理,不重写历史。 0.1.4 修复了新点赞、评论推送后无法取得详情的问题:复用网页自身请求层,自动补齐当前运行时参数,保持原始 ID 精度。 本次已经实际验证“新增点赞和评论 → 两条推送 → 两条完整详情”,未执行自动关注或私信。 diff --git a/build_windows.py b/build_windows.py index 1bd4eed..3e4f4f5 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.7", + "app_version": "0.1.8", "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.7-Windows-x64-Setup.exe" + installer = ROOT / "release/DouyinAccounts-0.1.8-Windows-x64-Setup.exe" (installer.parent / "SHA256SUMS.txt").write_text( sha256(installer) + " " + installer.name + "\n", encoding="ascii" ) diff --git a/docs/2026-09-07-14-49-persistent-history-work-cache.md b/docs/2026-09-07-14-49-persistent-history-work-cache.md new file mode 100644 index 0000000..5e13369 --- /dev/null +++ b/docs/2026-09-07-14-49-persistent-history-work-cache.md @@ -0,0 +1,132 @@ +# 作品与历史事件持久缓存方案(0.1.8) + +## 目标 + +将原来只存在于进程内存中的作品列表和历史事件预览改为 SQLite 持久缓存,并保持以下行为: + +- 首次同步获取平台当前可返回的全部数据,后续只增量读取新数据。 +- 程序重启后仍可查看已有作品和事件,不依赖重新全量请求。 +- 实时事件和历史事件共用本地事件缓存;实时来源优先,不会被后续历史同步降级。 +- 历史操作仍须“预览勾选 + 二次确认”,实时任务优先于历史任务。 +- 每个大号独立配置作品刷新间隔,默认 60 分钟,0 表示关闭。 +- 自动刷新只在对应账号组运行时发生;手动“强制增量刷新”始终可用。 +- 普通界面不显示数字时间戳和原始 JSON;完整业务字段仅保留在 SQLite 中用于定位。 + +## 数据库变更 + +### `cached_works` + +以 `(source, aweme_id)` 唯一保存每个大号的作品: + +- `create_time`:平台发布时间,内部保留数值。 +- `description`:作品描述。 +- `statistics`:点赞、评论、收藏、分享等完整统计 JSON。 +- `cover`:封面地址。 +- `business`:完整平台业务对象;认证字段写入前递归替换为 `[凭据已隐藏]`。 +- `updated`:本地最后合并时间。 + +重复作品使用 upsert 更新描述、统计、封面和业务对象,不产生重复行。 + +### `cached_notices` + +以 `(source, nid)` 唯一保存每个大号的历史和实时事件: + +- 提取并索引事件时间、类型、作品 ID、来源 UID/昵称、评论和作品描述。 +- `business` 保存经过认证字段保护的完整通知对象。 +- `origin` 为 `history` 或 `live`。 +- 同一通知先历史同步、后实时收到时,来源提升为 `live`;以后再次历史同步不会降回 `history`。 + +新增索引支持按账号/时间以及账号/作品/类型/时间读取。 + +### 规则与刷新状态 + +大号规则新增 `works_refresh_interval`,单位秒: + +- 默认 `3600`。 +- 有效范围 `0..604800`(最多 7 天)。 +- 旧规则自动补默认值,不改写已有作品范围、任务或账号归属。 + +`settings` 表使用 `works_refreshed_at:` 保存上次成功请求时间。旧数据库启动时通过 `CREATE TABLE IF NOT EXISTS` 和规则默认值直接迁移,无需用户手工操作。 + +## 同步流程 + +### 作品 + +1. 打开“作品监控”。 +2. 本地没有缓存时,分页读取全部历史作品并写入 SQLite。 +3. 本地已有缓存时,普通打开只读本地数据,不发送平台请求。 +4. 手动强制刷新或运行期间到达刷新间隔时,从最新页开始读取;遇到已缓存作品 ID 后停止继续翻页。 +5. 将返回作品 upsert 到缓存,再显示完整本地列表。 + +分页仍校验账号 UID、64 位 ID 字符串、返回结构和游标推进;游标不推进会立即拒绝,避免死循环。 + +### 历史事件 + +1. 每次打开“历史事件操作”都执行增量同步。 +2. 本地无事件时分页读取全部历史事件;已有事件时遇到缓存通知 ID 后停止翻页。 +3. 同步固定使用只读参数 `is_mark_read=0`,不会标记已读。 +4. 保存后按当前大号的作品监控规则过滤:指定作品模式只显示命中作品;关注事件不受作品筛选。 +5. UI 可再按中文事件类型过滤;仅当前显示并勾选的行进入二次确认。 + +实时监听调用原有 `Store.ingest()` 时先写入 `cached_notices`,因此没有匹配规则、没有可用小号或暂未创建任务的实时事件也仍可留作查看。 + +## 调度行为 + +账号组主循环读取该大号的刷新间隔: + +- 首次运行且从未同步作品时立即建立缓存。 +- 到达间隔后复用当前已登录会话进行增量刷新。 +- 刷新失败只记录原因,不中断通知监听;下一间隔再试。 +- 停止账号组会退出主循环,因此不会继续自动请求。 + +历史任务继续使用优先级 0,实时任务使用优先级 100。同 UID 实时事件可取消尚未执行的历史任务;已执行、执行中和待核对任务不重发、不静默改写。 + +## 界面变化 + +- 原“获取近期作品”和“获取全部历史作品”合并为一个“作品监控”。 +- 作品表显示正常日期、描述、ID、点赞、评论、收藏、分享、封面地址和本地更新时间。 +- 作品弹窗提供每大号刷新分钟数和“强制增量刷新”。 +- 历史表显示正常日期、中文类型、来源用户、作品、评论、通知 ID 和数据来源。 +- 历史表可按事件类型筛选;全选、清空和最终提交只作用于当前显示行。 +- 任务结果把常见平台字段转换为中文摘要;原始结构只在数据库中保留。 + +## 认证信息保护 + +作品和事件完整业务对象写库前复用 `account_log.visible()` 递归处理。Cookie、Authorization、Session、Token、密码、`verifyFp`、`a_bogus`、X-Bogus、X-TT-Params 和签名字段不会以可直接使用的值保存或显示。 + +## 验证 + +本地回归测试: + +```text +.venv/bin/python -m pytest -q +74 passed +``` + +新增测试覆盖: + +- 作品缓存重复 upsert。 +- SQLite 关闭重开后的作品、通知和刷新规则持久化。 +- 历史来源提升为实时来源且不会降级。 +- 指定作品过滤和关注事件例外。 +- 缓存业务对象的认证字段隐藏。 +- 新作品监控命令、刷新间隔保存和无 JSON 的用户界面。 + +Windows 发布验证结果: + +- Windows pytest:`74 passed`。 +- fingerprint-chromium/Patchright 浏览器矩阵:全部通过,`real_write_actions=0`。 +- 打包 GUI 冒烟:Qt、核心模块、SQLite、Patchright、浏览器均通过,`accounts=0`、`real_write_actions=0`。 +- 构建清单:版本 `0.1.8`,23 个 Python 源文件哈希全部一致。 +- 已登录真实账号只读验证:个人资料、历史事件、作品请求均为 HTTP 200、业务码 0,UID 归属正确,64 位 ID 保持字符串;未执行关注或私信。 +- 安装包:`222,660,069` 字节,SHA256 `9b91d9fcfb91a423e9730288b58c11d0dd9732c6c88cc663f059ff2f2a1141d8`。 + +## 空白 Windows 环境安装 + +最终安装包继续包含 Python、Qt、Patchright 和锁定版本的 fingerprint-chromium。用户只需运行: + +```text +DouyinAccounts-0.1.8-Windows-x64-Setup.exe +``` + +无需安装额外运行时。覆盖安装会保留 `%LOCALAPPDATA%\DouyinAccounts` 下的账号、数据库、登录目录、缓存、任务和日志。 diff --git a/installer.iss b/installer.iss index 76a79ef..bc0451a 100644 --- a/installer.iss +++ b/installer.iss @@ -2,14 +2,14 @@ [Setup] AppId={{C9C3EE3B-3666-4E41-A52B-BB309838F701} AppName=抖音账号助手 -AppVersion=0.1.7 +AppVersion=0.1.8 DefaultDirName={localappdata}\Programs\DouyinAccounts DefaultGroupName=抖音账号助手 PrivilegesRequired=lowest ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible OutputDir=release -OutputBaseFilename=DouyinAccounts-0.1.7-Windows-x64-Setup +OutputBaseFilename=DouyinAccounts-0.1.8-Windows-x64-Setup Compression=lzma2 SolidCompression=yes WizardStyle=modern diff --git a/src/account_engine.py b/src/account_engine.py index 2fcdd08..a1475cb 100644 --- a/src/account_engine.py +++ b/src/account_engine.py @@ -7,35 +7,10 @@ import time from account_browser import BrowserManager, validate_config from account_log import DailyLog, visible from account_session import Session, SessionError -from account_store import Store, decode, notice_kind, notice_work_id +from account_store import Store, decode from subscribe_notifications import notice_ids -def history_preview(notice): - kind = notice_kind(notice) - detail = notice.get(kind) or notice.get("favorite") or notice.get("collect") or {} - users = detail.get("from_user") or [] - if isinstance(users, dict): - users = [users] - comment = detail.get("comment") or {} - if not users and comment.get("user"): - users = [comment["user"]] - aweme = detail.get("aweme") or notice.get("aweme") or {} - return { - "nid": notice.get("nid_str") or str(notice.get("nid", "")), - "create_time": notice.get("create_time") or notice.get("createTime") or "", - "kind": kind, - "work_id": notice_work_id(notice), - "work_desc": aweme.get("desc") or "", - "actor_uids": [str(user.get("uid", "")) for user in users], - "actor_names": [ - user.get("nickname") or user.get("short_id") or "" for user in users - ], - "comment": comment.get("text") or detail.get("content") or "", - "business": notice, - } - - class Engine: def __init__(self, root, playwright): self.store = Store(root) @@ -56,8 +31,6 @@ class Engine: self.ready = set() self.last_write = {} self.verified = set() - self.history_cache = {} - self.works_cache = {} self.logger = self.audit.logger self.audit.record( "历史摘要", @@ -236,10 +209,39 @@ class Engine: checked = heartbeat = time.monotonic() pushed = added = 0 last_push = None + refreshed_at = self.store.setting( + "works_refreshed_at:" + ident + ) or self.store.works_updated(ident) + next_works_refresh = ( + refreshed_at + + decode(self.store.account(ident)["rule"])[ + "works_refresh_interval" + ] + ) while ident in self.desired: if time.monotonic() - checked >= 15: await session.identity() checked = time.monotonic() + refresh_interval = decode(self.store.account(ident)["rule"])[ + "works_refresh_interval" + ] + if refresh_interval and time.time() >= next_works_refresh: + try: + await self.fetch_works(ident, True, session) + except Exception as exc: + reason = ( + str(exc) + if isinstance(exc, (ValueError, RuntimeError)) + else type(exc).__name__ + ) + self.store.log( + "作品同步失败", + "定时增量刷新失败,本轮通知监听继续运行;到下个间隔再试", + logging.WARNING, + source=ident, + 原因=reason, + ) + next_works_refresh = time.time() + refresh_interval waiting = await self.collect_details(ident, session) self.state( ident, @@ -418,22 +420,24 @@ class Engine: if account["role"] != "main" or account["uid"] is None: raise ValueError("请选择已绑定身份的大号") session = await self.open(ident) + known = self.store.cached_notice_ids(ident) self.store.log( - "历史获取", "开始只读分页获取全部互动通知;不标记已读", source=ident - ) - notices = await session.history_notices() - self.history_cache[ident] = { - notice.get("nid_str") or str(notice.get("nid", "")): notice - for notice in notices - } - previews = [history_preview(notice) for notice in notices] - self.store.log( - "历史获取", - "全部可用历史事件已加载,等待用户预览和勾选;尚未创建任务", + "历史同步", + "开始增量同步历史事件;首次使用会全量缓存,不标记已读", source=ident, - 事件数=len(previews), + 本地已有=len(known), ) - return {"account": ident, "items": previews} + notices = await session.history_notices(known) + self.store.cache_notices(ident, notices, "history") + items = self.store.cached_notices(ident) + self.store.log( + "历史同步", + "历史事件已保存到本地,列表已按当前作品范围过滤;尚未创建任务", + source=ident, + 本次返回=len(notices), + 当前可选=len(items), + ) + return {"account": ident, "items": items} def enqueue_history(self, ident, ids): if ( @@ -446,18 +450,19 @@ class Engine: ): raise ValueError("历史事件选择无效") ids = list(dict.fromkeys(ids)) - cache = self.history_cache.get(ident) - if cache is None: - raise ValueError("历史事件预览已失效,请重新获取") - if any(item not in cache for item in ids): - raise ValueError("选择包含未加载的历史事件") + available = {item["nid"] for item in self.store.cached_notices(ident)} + if any(item not in available for item in ids): + raise ValueError("选择包含未缓存或不属于当前作品范围的历史事件") rule = decode(self.store.account(ident)["rule"]) if not rule["enabled"] or not (rule["follow"] or rule["dm"]): raise ValueError("请先启用大号规则并至少选择关注或私信") before = self.store.db.execute("SELECT count(*) FROM tasks").fetchone()[0] - added = sum( - self.store.ingest(ident, cache[item], origin="history") for item in ids - ) + added = 0 + for nid in ids: + notice = self.store.cached_notice(ident, nid) + if notice is None: + raise ValueError("历史事件缓存已损坏,请重新同步") + added += self.store.ingest(ident, notice, origin="history") tasks = ( self.store.db.execute("SELECT count(*) FROM tasks").fetchone()[0] - before ) @@ -471,51 +476,65 @@ class Engine: ) return {"selected": len(ids), "events": added, "tasks": tasks} - async def fetch_works(self, ident, all_pages): + async def fetch_works(self, ident, force=False, session=None): account = self.store.account(ident) if account["role"] != "main" or account["uid"] is None: raise ValueError("请选择已绑定身份的大号") - if type(all_pages) is not bool: - raise ValueError("作品获取范围无效") - session = await self.open(ident) - self.store.log( - "作品获取", - "开始只读获取全部历史作品" if all_pages else "开始只读获取近期作品", - source=ident, - ) - works = await session.works(all_pages) - self.works_cache[ident] = {work["aweme_id"]: work for work in works} - self.store.log( - "作品获取", - "作品列表已加载,等待用户选择监控范围", - source=ident, - 作品数=len(works), - 范围="全部历史" if all_pages else "近期", - ) + if type(force) is not bool: + raise ValueError("作品刷新参数无效") + known = self.store.cached_work_ids(ident) + refreshed = 0 + full = not known + if full or force: + session = session or await self.open(ident) + self.store.log( + "作品同步", + "首次建立全部历史作品缓存" if full else "开始增量刷新作品缓存", + source=ident, + 本地已有=len(known), + ) + works = await session.works(full, known if known else None) + refreshed = self.store.cache_works(ident, works) + self.store.set_setting("works_refreshed_at:" + ident, time.time()) + items = self.store.cached_works(ident) rule = decode(account["rule"]) + self.store.log( + "作品同步", + "作品缓存可用,等待用户选择监控范围", + source=ident, + 本次更新=refreshed, + 本地总数=len(items), + ) return { "account": ident, - "items": works, + "items": items, "mode": rule["work_mode"], "selected": rule["work_ids"], - "all_pages": all_pages, + "refresh_interval": rule["works_refresh_interval"], + "refreshed": refreshed, } - def save_work_filter(self, ident, mode, ids): + def save_work_filter(self, ident, mode, ids, refresh_interval): if mode not in ("all", "selected") or not isinstance(ids, list): raise ValueError("作品监控选择无效") ids = list(dict.fromkeys(ids)) if mode == "selected": if not ids: raise ValueError("指定作品模式至少选择一个作品") - cache = self.works_cache.get(ident) - existing = decode(self.store.account(ident)["rule"])["work_ids"] - if cache is None or any( - item not in cache and item not in existing for item in ids - ): - raise ValueError("作品列表已失效,请重新获取") - self.store.set_work_filter(ident, mode, ids if mode == "selected" else []) - return {"mode": mode, "count": len(ids) if mode == "selected" else 0} + cached = self.store.cached_work_ids(ident) + if any(item not in cached for item in ids): + raise ValueError("选择包含未缓存的作品,请强制刷新后重试") + self.store.set_work_filter( + ident, + mode, + ids if mode == "selected" else [], + refresh_interval, + ) + return { + "mode": mode, + "count": len(ids) if mode == "selected" else 0, + "refresh_interval": refresh_interval, + } async def command(self, name, data): labels = { @@ -533,8 +552,8 @@ class Engine: "delete_worker": "删除小号", "history_fetch": "获取全部历史事件", "history_enqueue": "确认历史事件操作", - "works_recent": "获取近期作品", - "works_all": "获取全部历史作品", + "works_open": "打开作品监控", + "works_refresh": "强制增量刷新作品", "work_filter": "保存作品监控范围", } label = labels.get(name, "未知操作") @@ -578,10 +597,15 @@ class Engine: return await self.fetch_history(data["id"]) elif name == "history_enqueue": return self.enqueue_history(data["id"], data.get("ids")) - elif name in ("works_recent", "works_all"): - return await self.fetch_works(data["id"], name == "works_all") + elif name in ("works_open", "works_refresh"): + return await self.fetch_works(data["id"], name == "works_refresh") elif name == "work_filter": - return self.save_work_filter(data["id"], data.get("mode"), data.get("ids")) + return self.save_work_filter( + data["id"], + data.get("mode"), + data.get("ids"), + data.get("refresh_interval"), + ) elif name == "move": self.store.move(data["id"], data.get("owner")) owner = data.get("owner") diff --git a/src/account_session.py b/src/account_session.py index dc9658d..d3807b7 100644 --- a/src/account_session.py +++ b/src/account_session.py @@ -232,8 +232,9 @@ class Session: raise SessionError("通知详情返回了未请求的 ID,已停止处理") return notices - async def history_notices(self): + async def history_notices(self, stop_ids=None): notices = {} + stop_ids = set(stop_ids or ()) min_time = max_time = 0 seen_cursors = set() for _ in range(1000): @@ -256,13 +257,14 @@ class Session: not isinstance(row, dict) for row in rows ): raise SessionError("历史通知列表格式无效") + reached_cache = False for row in rows: if str(row.get("user_id")) != self.uid: raise SessionError("历史通知所属身份不符") - nid = row.get("nid_str") or row.get("nid") - validate_uid(nid) + nid = validate_uid(row.get("nid_str") or row.get("nid")) + reached_cache = reached_cache or nid in stop_ids notices.setdefault(nid, row) - if not payload.get("has_more"): + if reached_cache or not payload.get("has_more"): return list(notices.values()) cursor = (payload.get("min_time"), payload.get("max_time")) if cursor in seen_cursors or cursor == (min_time, max_time): @@ -273,15 +275,16 @@ class Session: min_time, max_time = cursor raise SessionError("历史通知超过 50000 条,已停止以避免无限分页") - async def works(self, all_pages=False): + async def works(self, all_pages=False, stop_ids=None): profile = await self.identity() + stop_ids = set(stop_ids or ()) sec_uid = profile.get("sec_uid") or profile.get("secUid") if not isinstance(sec_uid, str) or not sec_uid: raise SessionError("当前账号缺少作品列表身份参数") works = {} cursor = 0 seen_cursors = set() - pages = 1000 if all_pages else 1 + pages = 1000 if all_pages or stop_ids else 1 for _ in range(pages): response = await self.json( works_request_script(sec_uid, cursor), main_world=True @@ -302,12 +305,13 @@ class Session: not isinstance(row, dict) for row in rows ): raise SessionError("作品列表格式无效") + reached_cache = False for row in rows: author = row.get("author") or {} if str(author.get("uid")) != self.uid: raise SessionError("作品列表所属身份不符") - ident = row.get("aweme_id") or row.get("awemeId") - validate_uid(ident) + ident = validate_uid(row.get("aweme_id") or row.get("awemeId")) + reached_cache = reached_cache or ident in stop_ids cover = ((row.get("video") or {}).get("cover") or {}).get( "url_list" ) or [] @@ -324,7 +328,11 @@ class Session: "business": row, }, ) - if not all_pages or not payload.get("has_more"): + if ( + reached_cache + or (not all_pages and not stop_ids) + or not payload.get("has_more") + ): return list(works.values()) next_cursor = payload.get("max_cursor") if type(next_cursor) not in (int, float) or next_cursor < 0: diff --git a/src/account_store.py b/src/account_store.py index 7469156..dc6a29f 100644 --- a/src/account_store.py +++ b/src/account_store.py @@ -18,6 +18,7 @@ def validate_rule(rule): rule.setdefault("cooldown", 14400) rule.setdefault("work_mode", "all") rule.setdefault("work_ids", []) + rule.setdefault("works_refresh_interval", 3600) kinds = rule.get("kinds", []) if not isinstance(kinds, list) or any( k not in ("digg", "follow", "comment", "general_notice") for k in kinds @@ -48,6 +49,11 @@ def validate_rule(rule): ): raise ValueError("作品 ID 列表无效") rule["work_ids"] = list(dict.fromkeys(rule["work_ids"])) + if ( + type(rule["works_refresh_interval"]) is not int + or not 0 <= rule["works_refresh_interval"] <= 604800 + ): + raise ValueError("作品自动刷新间隔需为 0..604800 秒") return rule @@ -62,6 +68,7 @@ DEFAULT_RULE = { "cooldown": 14400, "work_mode": "all", "work_ids": [], + "works_refresh_interval": 3600, } @@ -102,6 +109,31 @@ def notice_kind(notice): return "general_notice" +def notice_preview(notice): + kind = notice_kind(notice) + detail = notice.get(kind) or notice.get("favorite") or notice.get("collect") or {} + users = detail.get("from_user") or [] + if isinstance(users, dict): + users = [users] + comment = detail.get("comment") or {} + if not users and comment.get("user"): + users = [comment["user"]] + aweme = detail.get("aweme") or notice.get("aweme") or {} + return { + "nid": notice.get("nid_str") or str(notice.get("nid", "")), + "create_time": notice.get("create_time") or notice.get("createTime") or "", + "kind": kind, + "work_id": notice_work_id(notice), + "work_desc": aweme.get("desc") or "", + "actor_uids": [str(user.get("uid", "")) for user in users], + "actor_names": [ + user.get("nickname") or user.get("short_id") or "" for user in users + ], + "comment": comment.get("text") or detail.get("content") or "", + "business": notice, + } + + class Store: def __init__(self, root, audit=None): self.audit = audit @@ -147,6 +179,23 @@ class Store: source TEXT NOT NULL REFERENCES accounts(id), target TEXT NOT NULL, last_at REAL NOT NULL, event INTEGER NOT NULL REFERENCES events(id), PRIMARY KEY(source,target)); + CREATE TABLE IF NOT EXISTS cached_works ( + source TEXT NOT NULL REFERENCES accounts(id), aweme_id TEXT NOT NULL, + create_time REAL NOT NULL DEFAULT 0, description TEXT NOT NULL DEFAULT '', + statistics TEXT NOT NULL DEFAULT '{}', cover TEXT NOT NULL DEFAULT '', + business TEXT NOT NULL, updated REAL NOT NULL, + PRIMARY KEY(source,aweme_id)); + CREATE INDEX IF NOT EXISTS cached_works_time ON cached_works(source,create_time DESC); + CREATE TABLE IF NOT EXISTS cached_notices ( + source TEXT NOT NULL REFERENCES accounts(id), nid TEXT NOT NULL, + create_time REAL NOT NULL DEFAULT 0, kind TEXT NOT NULL, + work_id TEXT NOT NULL DEFAULT '', actor_uids TEXT NOT NULL DEFAULT '[]', + actor_names TEXT NOT NULL DEFAULT '[]', comment TEXT NOT NULL DEFAULT '', + work_desc TEXT NOT NULL DEFAULT '', business TEXT NOT NULL, + origin TEXT NOT NULL CHECK(origin IN ('live','history')), updated REAL NOT NULL, + PRIMARY KEY(source,nid)); + CREATE INDEX IF NOT EXISTS cached_notices_time ON cached_notices(source,create_time DESC); + CREATE INDEX IF NOT EXISTS cached_notices_work ON cached_notices(source,work_id,kind,create_time DESC); DROP INDEX IF EXISTS worker_single_running; """) self.migrate_accounts() @@ -351,7 +400,13 @@ class Store: "首次身份核验通过,绑定已锁定;业务资料按原文输出,认证凭据保持隐藏", account=ident, UID=uid, - 平台资料=saved, + 昵称=saved.get("nickname") or "", + 抖音号=saved.get("douyin_id") or "", + 头像URL=saved.get("avatar_url") or "", + 关注数=saved.get("following_count") or 0, + 粉丝数=saved.get("follower_count") or 0, + 获赞数=saved.get("total_favorited") or 0, + 作品数=saved.get("aweme_count") or 0, ) self.db.execute( "UPDATE accounts SET uid=?,nickname=?,avatar=?,profile=? WHERE id=?", @@ -383,16 +438,164 @@ class Store: 动作间隔秒=rule["interval"], 同组UID冷却秒=rule["cooldown"], 作品监控模式=rule["work_mode"], - 作品ID=rule["work_ids"], + 作品ID=",".join(rule["work_ids"]), 私信正文=rule["text"], ) - def set_work_filter(self, ident, mode, ids): + def set_work_filter(self, ident, mode, ids, refresh_interval=None): rule = decode(self.account(ident)["rule"]) rule["work_mode"] = mode rule["work_ids"] = ids + if refresh_interval is not None: + rule["works_refresh_interval"] = refresh_interval self.set_rule(ident, rule) + def cache_works(self, source, works): + self.account(source) + now = time.time() + with self.transaction(): + for work in works: + ident = validate_uid(work.get("aweme_id")) + created = work.get("create_time") or 0 + if type(created) not in (int, float): + created = 0 + self.db.execute( + """INSERT INTO cached_works(source,aweme_id,create_time,description,statistics,cover,business,updated) + VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source,aweme_id) DO UPDATE SET + create_time=excluded.create_time,description=excluded.description, + statistics=excluded.statistics,cover=excluded.cover,business=excluded.business,updated=excluded.updated""", + ( + source, + ident, + created, + str(work.get("desc") or ""), + json.dumps(work.get("statistics") or {}, ensure_ascii=False), + str(work.get("cover") or ""), + json.dumps( + visible(work.get("business") or work), ensure_ascii=False + ), + now, + ), + ) + return len(works) + + def cached_work_ids(self, source): + return { + row["aweme_id"] + for row in self.db.execute( + "SELECT aweme_id FROM cached_works WHERE source=?", (source,) + ) + } + + def cached_works(self, source): + self.account(source) + return [ + { + "aweme_id": row["aweme_id"], + "desc": row["description"], + "create_time": row["create_time"], + "statistics": decode(row["statistics"]), + "cover": row["cover"], + "updated": row["updated"], + } + for row in self.db.execute( + "SELECT * FROM cached_works WHERE source=? ORDER BY create_time DESC,aweme_id DESC", + (source,), + ) + ] + + def works_updated(self, source): + row = self.db.execute( + "SELECT max(updated) AS updated FROM cached_works WHERE source=?", (source,) + ).fetchone() + return row["updated"] or 0 + + def cache_notices(self, source, notices, origin): + account = self.account(source) + if origin not in ("live", "history"): + raise ValueError("通知缓存来源无效") + now = time.time() + with self.transaction(): + for notice in notices: + if str(notice.get("user_id")) != account["uid"]: + raise ValueError("通知缓存所属身份不符") + preview = notice_preview(notice) + nid = validate_uid(preview["nid"]) + created = preview["create_time"] or 0 + if type(created) not in (int, float): + created = 0 + if created > 10_000_000_000: + created /= 1000 + self.db.execute( + """INSERT INTO cached_notices(source,nid,create_time,kind,work_id,actor_uids,actor_names,comment,work_desc,business,origin,updated) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(source,nid) DO UPDATE SET + create_time=excluded.create_time,kind=excluded.kind,work_id=excluded.work_id, + actor_uids=excluded.actor_uids,actor_names=excluded.actor_names, + comment=excluded.comment,work_desc=excluded.work_desc,business=excluded.business, + origin=CASE WHEN cached_notices.origin='live' OR excluded.origin='live' THEN 'live' ELSE 'history' END, + updated=excluded.updated""", + ( + source, + nid, + created, + preview["kind"], + preview["work_id"], + json.dumps(preview["actor_uids"], ensure_ascii=False), + json.dumps(preview["actor_names"], ensure_ascii=False), + preview["comment"], + preview["work_desc"], + json.dumps(visible(notice), ensure_ascii=False), + origin, + now, + ), + ) + return len(notices) + + def cached_notice_ids(self, source): + return { + row["nid"] + for row in self.db.execute( + "SELECT nid FROM cached_notices WHERE source=?", (source,) + ) + } + + def cached_notices(self, source): + rule = decode(self.account(source)["rule"]) + rows = self.db.execute( + "SELECT * FROM cached_notices WHERE source=? ORDER BY create_time DESC,nid DESC", + (source,), + ) + selected = set(rule["work_ids"]) + result = [] + for row in rows: + if ( + rule["work_mode"] == "selected" + and row["kind"] != "follow" + and row["work_id"] not in selected + ): + continue + result.append( + { + "nid": row["nid"], + "create_time": row["create_time"], + "kind": row["kind"], + "work_id": row["work_id"], + "work_desc": row["work_desc"], + "actor_uids": decode(row["actor_uids"]), + "actor_names": decode(row["actor_names"]), + "comment": row["comment"], + "origin": row["origin"], + } + ) + return result + + def cached_notice(self, source, nid): + row = self.db.execute( + "SELECT business FROM cached_notices WHERE source=? AND nid=?", + (source, nid), + ).fetchone() + return decode(row["business"]) if row else None + def move(self, worker, owner): with self.transaction(): account = self.account(worker) @@ -528,6 +731,7 @@ class Store: if account["role"] != "main" or str(notice.get("user_id")) != account["uid"]: raise ValueError("通知所属身份不符") nid = validate_uid(notice.get("nid_str") or str(notice.get("nid", ""))) + self.cache_notices(source, [notice], origin) kind = notice_kind(notice) detail = ( notice.get(kind) or notice.get("favorite") or notice.get("collect") or {} @@ -612,7 +816,6 @@ class Store: }[kind], 作品ID=work_id, 目标数量=len(targets), - 业务数据=notice, ) self.log( "规则匹配" if enabled else "通知跳过", diff --git a/src/accounts_app.py b/src/accounts_app.py index 4e22163..a01d160 100644 --- a/src/accounts_app.py +++ b/src/accounts_app.py @@ -6,6 +6,7 @@ import json import queue import sys import tempfile +from datetime import datetime from pathlib import Path from patchright.async_api import async_playwright @@ -44,6 +45,55 @@ from account_log import UI_LIMIT, DailyLog from account_store import decode, validate_rule +def date_text(value): + try: + stamp = float(value) + except (TypeError, ValueError): + return "未知时间" + if stamp > 10_000_000_000: + stamp /= 1000 + try: + return datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M:%S") + except (OSError, OverflowError, ValueError): + return "未知时间" + + +def task_result_text(value): + if not value: + return "尚无结果" + try: + result = json.loads(value) if isinstance(value, str) else value + except json.JSONDecodeError: + return str(value) + if not isinstance(result, dict): + return str(result) + parts = [] + for key, label in { + "code": "结果代码", + "status_code": "平台状态码", + "error": "错误", + "status": "状态", + "success": "是否成功", + "client_id": "客户端消息ID", + "server_id": "服务端消息ID", + }.items(): + if key in result and result[key] not in (None, ""): + parts.append(f"{label}:{result[key]}") + message = result.get("message") + if isinstance(message, dict): + if message.get("content"): + parts.append("消息内容:" + str(message["content"])) + for key, label in ( + ("client_id", "客户端消息ID"), + ("server_id", "服务端消息ID"), + ): + if message.get(key): + parts.append(f"{label}:{message[key]}") + elif message: + parts.append("平台说明:" + str(message)) + return ";".join(dict.fromkeys(parts)) or "平台业务结果已保存,可在底层数据库中定位" + + class Backend(QThread): snapshot_ready = Signal(dict) response = Signal(str, bool, str) @@ -211,8 +261,7 @@ class Window(QMainWindow): ("删除登录数据", self.erase), ("删除小号", self.delete_worker), ("历史事件操作", self.history_events), - ("获取近期作品", lambda: self.fetch_works(False)), - ("获取全部历史作品", lambda: self.fetch_works(True)), + ("作品监控", self.fetch_works), ]: self.button(actions, text, handler) self.tabs.addTab(accounts, "账号组") @@ -233,7 +282,7 @@ class Window(QMainWindow): "目标 UID", "动作", "状态", - "结果(不含正文)", + "结果说明", ] ) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) @@ -343,17 +392,17 @@ class Window(QMainWindow): return self.send("history_fetch", {"id": account["id"]}) - def fetch_works(self, all_pages): + def fetch_works(self): account = self.selected() if not account or account["role"] != "main" or not account["uid"]: QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。") return - self.send("works_all" if all_pages else "works_recent", {"id": account["id"]}) + self.send("works_open", {"id": account["id"]}) def command_data(self, name, payload): if name == "history_fetch": self.history_dialog(payload) - elif name in ("works_recent", "works_all"): + elif name in ("works_open", "works_refresh"): self.works_dialog(payload) elif name == "history_enqueue": QMessageBox.information( @@ -373,7 +422,7 @@ class Window(QMainWindow): def set_table_checks(table, state): for row in range(table.rowCount()): item = table.item(row, 0) - if item is not None: + if item is not None and not table.isRowHidden(row): item.setCheckState(state) @staticmethod @@ -381,34 +430,56 @@ class Window(QMainWindow): values = [] for row in range(table.rowCount()): item = table.item(row, 0) - if item is not None and item.checkState() == Qt.CheckState.Checked: + if ( + item is not None + and not table.isRowHidden(row) + and item.checkState() == Qt.CheckState.Checked + ): values.append(item.data(Qt.ItemDataRole.UserRole)) return values def history_dialog(self, payload): items = payload["items"] + labels = { + "digg": "点赞/作品互动", + "comment": "评论", + "follow": "关注", + "general_notice": "其他作品互动", + } win = QDialog(self) - win.setWindowTitle(f"历史事件预览 — 共 {len(items)} 条(尚未创建任务)") + win.setWindowTitle(f"历史事件预览 — 当前可选 {len(items)} 条(尚未创建任务)") win.resize(1500, 800) layout = QVBoxLayout(win) note = QLabel( - "勾选并确认后才按当前规则分配。历史任务优先级低于新消息;同组 UID 冷却、作品筛选和自操作保护继续生效。业务字段完整显示,认证凭据仍不输出。" + "列表来自本地缓存,并已按当前作品监控范围过滤。勾选并二次确认后才会产生真实关注或私信;新消息始终优先。完整平台数据只保存在本地数据库中。" ) note.setWordWrap(True) layout.addWidget(note) - table = QTableWidget(len(items), 10) + controls = QHBoxLayout() + controls.addWidget(QLabel("只看事件类型")) + kind_filter = QComboBox() + kind_filter.addItem("全部类型", "") + for kind in dict.fromkeys(item["kind"] for item in items): + kind_filter.addItem(labels.get(kind, "其他互动"), kind) + controls.addWidget(kind_filter) + select_all = QPushButton("全选当前显示") + clear = QPushButton("清空当前显示") + controls.addWidget(select_all) + controls.addWidget(clear) + controls.addStretch() + layout.addLayout(controls) + table = QTableWidget(len(items), 9) table.setHorizontalHeaderLabels( [ "选择", - "时间", - "类型", - "来源UID", - "来源昵称", + "发生时间", + "事件类型", + "来源用户", + "作品名称", "作品ID", - "作品描述", - "评论/内容", + "评论或内容", "通知ID", - "完整业务数据", + "数据来源", ] ) for row, item in enumerate(items): @@ -416,22 +487,25 @@ class Window(QMainWindow): check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) check.setCheckState(Qt.CheckState.Unchecked) check.setData(Qt.ItemDataRole.UserRole, item["nid"]) + check.setData(Qt.ItemDataRole.UserRole + 1, item["kind"]) table.setItem(row, 0, check) + users = [] + for index, uid in enumerate(item["actor_uids"]): + name = ( + item["actor_names"][index] + if index < len(item["actor_names"]) + else "" + ) + users.append(f"{name or '未知用户'}(UID {uid})") values = [ - item["create_time"], - { - "digg": "点赞/作品互动", - "comment": "评论", - "follow": "关注", - "general_notice": "其他作品互动", - }.get(item["kind"], item["kind"]), - ", ".join(item["actor_uids"]), - ", ".join(item["actor_names"]), - item["work_id"], - item["work_desc"], - item["comment"], + date_text(item["create_time"]), + labels.get(item["kind"], "其他互动"), + "、".join(users) or "未知用户", + item["work_desc"] or "未提供作品名称", + item["work_id"] or "无作品ID", + item["comment"] or "无文字内容", item["nid"], - json.dumps(item["business"], ensure_ascii=False, default=str), + "实时收到" if item.get("origin") == "live" else "历史同步", ] for column, value in enumerate(values, 1): cell = QTableWidgetItem(str(value)) @@ -440,21 +514,29 @@ class Window(QMainWindow): table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeMode.ResizeToContents ) - table.horizontalHeader().setSectionResizeMode(9, QHeaderView.ResizeMode.Stretch) + table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) layout.addWidget(table) - controls = QHBoxLayout() - select_all = QPushButton("全选") - clear = QPushButton("清空选择") + + def apply_filter(): + selected_kind = kind_filter.currentData() + for row in range(table.rowCount()): + item = table.item(row, 0) + table.setRowHidden( + row, + bool(selected_kind) + and ( + item is None + or item.data(Qt.ItemDataRole.UserRole + 1) != selected_kind + ), + ) + + kind_filter.currentIndexChanged.connect(apply_filter) select_all.clicked.connect( lambda: self.set_table_checks(table, Qt.CheckState.Checked) ) clear.clicked.connect( lambda: self.set_table_checks(table, Qt.CheckState.Unchecked) ) - controls.addWidget(select_all) - controls.addWidget(clear) - controls.addStretch() - layout.addLayout(controls) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) @@ -465,7 +547,7 @@ class Window(QMainWindow): return selected = self.checked_table_data(table) if not selected: - QMessageBox.information(self, "没有选择", "未创建任何历史任务。") + QMessageBox.information(self, "没有选择", "当前显示范围内未选择任何事件。") return if ( QMessageBox.question( @@ -481,39 +563,47 @@ class Window(QMainWindow): def works_dialog(self, payload): items = payload["items"] - loaded = {item["aweme_id"] for item in items} existing = set(payload["selected"]) - hidden = existing - loaded win = QDialog(self) - win.setWindowTitle( - ("全部历史作品" if payload["all_pages"] else "近期作品") - + f" — {len(items)} 条" - ) - win.resize(1500, 800) + win.setWindowTitle(f"作品监控 — 本地已缓存 {len(items)} 个作品") + win.resize(1600, 800) layout = QVBoxLayout(win) - all_mode = QCheckBox("监控全部作品(保持兼容;新作品自动包含)") + all_mode = QCheckBox("监控全部作品(以后发布的新作品也自动包含)") all_mode.setChecked(payload["mode"] == "all") layout.addWidget(all_mode) note = QLabel( - "取消上方选项后,仅监控勾选作品的点赞、评论、收藏或其他带作品 ID 的互动;关注通知不受作品筛选。指定模式下新发布作品不会自动加入。" - + ( - f"\n当前还有 {len(hidden)} 个已选历史作品未包含在本次近期列表中,将继续保留;使用“获取全部历史作品”可统一管理。" - if hidden - else "" - ) + "取消上方选项后,只监控表格中勾选作品的点赞、评论、收藏等互动;关注通知不受作品筛选。完整平台数据只保存在本地数据库中。" ) note.setWordWrap(True) layout.addWidget(note) - table = QTableWidget(len(items), 7) + schedule = QHBoxLayout() + schedule.addWidget(QLabel("运行期间自动刷新间隔")) + refresh_minutes = QSpinBox() + refresh_minutes.setRange(0, 10080) + refresh_minutes.setSpecialValueText("关闭") + refresh_minutes.setSuffix(" 分钟") + refresh_minutes.setValue(payload.get("refresh_interval", 3600) // 60) + schedule.addWidget(refresh_minutes) + refresh_now = QPushButton("强制增量刷新") + schedule.addWidget(refresh_now) + schedule.addWidget( + QLabel("首次打开会自动获取全部历史作品;停止账号组后不会定时请求。") + ) + schedule.addStretch() + layout.addLayout(schedule) + table = QTableWidget(len(items), 10) table.setHorizontalHeaderLabels( [ "选择", "发布时间", "作品描述", "作品ID", - "统计", - "封面URL", - "完整业务数据", + "点赞", + "评论", + "收藏", + "分享", + "封面地址", + "本地更新时间", ] ) for row, item in enumerate(items): @@ -526,13 +616,17 @@ class Window(QMainWindow): ) check.setData(Qt.ItemDataRole.UserRole, item["aweme_id"]) table.setItem(row, 0, check) + statistics = item["statistics"] values = [ - item["create_time"], - item["desc"], + date_text(item["create_time"]), + item["desc"] or "未填写作品描述", item["aweme_id"], - json.dumps(item["statistics"], ensure_ascii=False, default=str), - item["cover"], - json.dumps(item["business"], ensure_ascii=False, default=str), + statistics.get("digg_count", 0), + statistics.get("comment_count", 0), + statistics.get("collect_count", 0), + statistics.get("share_count", 0), + item["cover"] or "无封面地址", + date_text(item.get("updated")), ] for column, value in enumerate(values, 1): cell = QTableWidgetItem(str(value)) @@ -541,13 +635,13 @@ class Window(QMainWindow): table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeMode.ResizeToContents ) - table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) + table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) table.setDisabled(all_mode.isChecked()) all_mode.toggled.connect(table.setDisabled) layout.addWidget(table) controls = QHBoxLayout() - select_all = QPushButton("全选当前列表") - clear = QPushButton("清空当前列表") + select_all = QPushButton("全选") + clear = QPushButton("清空选择") select_all.clicked.connect( lambda: self.set_table_checks(table, Qt.CheckState.Checked) ) @@ -562,15 +656,21 @@ class Window(QMainWindow): QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel ) + buttons.button(QDialogButtonBox.StandardButton.Save).setText("保存设置") + buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") buttons.accepted.connect(win.accept) buttons.rejected.connect(win.reject) layout.addWidget(buttons) + + def force_refresh(): + win.reject() + self.send("works_refresh", {"id": payload["account"]}) + + refresh_now.clicked.connect(force_refresh) if win.exec() != QDialog.DialogCode.Accepted: return mode = "all" if all_mode.isChecked() else "selected" - selected = list(hidden) - selected.extend(self.checked_table_data(table)) - selected = list(dict.fromkeys(selected)) + selected = self.checked_table_data(table) if mode == "selected" and not selected: QMessageBox.warning( self, @@ -579,7 +679,13 @@ class Window(QMainWindow): ) return self.send( - "work_filter", {"id": payload["account"], "mode": mode, "ids": selected} + "work_filter", + { + "id": payload["account"], + "mode": mode, + "ids": selected, + "refresh_interval": refresh_minutes.value() * 60, + }, ) def selected(self): @@ -724,6 +830,7 @@ class Window(QMainWindow): "cooldown": cooldown.value() * 60, "work_mode": rule.get("work_mode", "all"), "work_ids": rule.get("work_ids", []), + "works_refresh_interval": rule.get("works_refresh_interval", 3600), } try: validate_rule(value) @@ -995,7 +1102,7 @@ class Window(QMainWindow): task["target"], "关注" if task["action"] == "follow" else "私信", labels[task["status"]], - task["result"], + task_result_text(task["result"]), ] for col, value in enumerate(values): self.table.setItem(row, col, QTableWidgetItem(str(value))) diff --git a/src/test_accounts.py b/src/test_accounts.py index f0e7252..0e3649c 100644 --- a/src/test_accounts.py +++ b/src/test_accounts.py @@ -218,6 +218,66 @@ class StoreTest(unittest.TestCase): {states[nid] for nid in ("1011", "1013", "1014")}, {"dispatched"} ) + def test_persistent_caches_merge_incrementally_filter_and_hide_credentials(self): + self.store.cache_works( + self.main, + [ + { + "aweme_id": "700", + "create_time": 100, + "desc": "旧描述", + "statistics": {"digg_count": 1}, + "cover": "https://example.invalid/cover", + "business": {"Authorization": "secret", "desc": "旧描述"}, + } + ], + ) + self.store.cache_works( + self.main, + [ + { + "aweme_id": "700", + "create_time": 100, + "desc": "新描述", + "statistics": {"digg_count": 2}, + "cover": "https://example.invalid/cover", + "business": {"Authorization": "secret", "desc": "新描述"}, + } + ], + ) + selected = work_notice("101", "3001", "901", "700") + other = work_notice("101", "3002", "902", "701") + followed = notice("101", "3003", "903") + selected["Authorization"] = "secret" + self.store.cache_notices(self.main, [selected, other, followed], "history") + self.store.cache_notices(self.main, [selected], "live") + self.store.set_work_filter(self.main, "selected", ["700"], 0) + + self.store.close() + self.store = Store(self.temp.name) + + works = self.store.cached_works(self.main) + self.assertEqual(len(works), 1) + self.assertEqual(works[0]["desc"], "新描述") + self.assertEqual(works[0]["statistics"]["digg_count"], 2) + work_business = json.loads( + self.store.db.execute( + "SELECT business FROM cached_works WHERE source=? AND aweme_id=?", + (self.main, "700"), + ).fetchone()["business"] + ) + self.assertEqual(work_business["Authorization"], "[凭据已隐藏]") + cached = {item["nid"]: item for item in self.store.cached_notices(self.main)} + self.assertEqual(set(cached), {"3001", "3003"}) + self.assertEqual(cached["3001"]["origin"], "live") + business = self.store.cached_notice(self.main, "3001") + assert business is not None + self.assertEqual(business["Authorization"], "[凭据已隐藏]") + self.assertEqual( + json.loads(self.store.account(self.main)["rule"])["works_refresh_interval"], + 0, + ) + def test_live_tasks_preempt_history_and_promote_same_notification(self): self.store.set_rule(self.main, rule(dm=False, cooldown=0)) self.store.ingest(self.main, notice("101", "2001", "901"), origin="history") @@ -657,12 +717,18 @@ class EngineTest(unittest.IsolatedAsyncioTestCase): await self.engine.start([ident]) self.assertEqual(self.engine.desired, set()) - async def test_im_result_classification_keeps_business_data_and_hides_credentials(self): + async def test_im_result_classification_keeps_business_data_and_hides_credentials( + self, + ): session = AsyncMock() task = {"params": json.dumps(rule()), "action": "dm", "target": "999"} session.im.return_value = { "success": True, - "message": {"client_id": "c1", "server_id": "s1", "content": "完整私信正文"}, + "message": { + "client_id": "c1", + "server_id": "s1", + "content": "完整私信正文", + }, "token": "AUTH-CREDENTIAL", } status, result = await self.engine.perform(session, task) @@ -939,18 +1005,27 @@ class EngineTest(unittest.IsolatedAsyncioTestCase): "history_enqueue", {"id": self.main, "ids": ["3001"]} ) self.assertEqual(result, {"selected": 1, "events": 1, "tasks": 2}) - fetched = await self.engine.command("works_recent", {"id": self.main}) - self.assertEqual(fetched["items"], works) - self.assertFalse(fetched["all_pages"]) + fetched = await self.engine.command("works_open", {"id": self.main}) + self.assertEqual(fetched["items"][0]["aweme_id"], "700") + self.assertEqual(fetched["refresh_interval"], 3600) saved = await self.engine.command( - "work_filter", {"id": self.main, "mode": "selected", "ids": ["700"]} + "work_filter", + { + "id": self.main, + "mode": "selected", + "ids": ["700"], + "refresh_interval": 120, + }, + ) + self.assertEqual( + saved, {"mode": "selected", "count": 1, "refresh_interval": 120} ) - self.assertEqual(saved, {"mode": "selected", "count": 1}) rule_value = json.loads(self.engine.store.account(self.main)["rule"]) self.assertEqual(rule_value["work_mode"], "selected") self.assertEqual(rule_value["work_ids"], ["700"]) - session.works.assert_awaited_once_with(False) - session.history_notices.assert_awaited_once() + self.assertEqual(rule_value["works_refresh_interval"], 120) + session.works.assert_awaited_once_with(True, None) + session.history_notices.assert_awaited_once_with(set()) async def test_move_during_identity_check_does_not_cross_groups(self): other = self.engine.store.add("main", "另一组", "102") diff --git a/src/test_onboarding_ui.py b/src/test_onboarding_ui.py index d906fa9..3c0beb0 100644 --- a/src/test_onboarding_ui.py +++ b/src/test_onboarding_ui.py @@ -150,14 +150,12 @@ class OnboardingUiTest(unittest.TestCase): patch.object(self.window, "send") as send, ): self.window.history_events() - self.window.fetch_works(False) - self.window.fetch_works(True) + self.window.fetch_works() self.assertEqual( [call.args for call in send.call_args_list], [ ("history_fetch", {"id": "main"}), - ("works_recent", {"id": "main"}), - ("works_all", {"id": "main"}), + ("works_open", {"id": "main"}), ], ) @@ -174,6 +172,7 @@ class OnboardingUiTest(unittest.TestCase): "actor_uids": ["999"], "actor_names": ["完整昵称"], "comment": "完整评论正文", + "origin": "history", "business": { "nid_str": "9007199254740993", "comment": "完整评论正文", @@ -184,8 +183,9 @@ class OnboardingUiTest(unittest.TestCase): def choose(dialog): table = dialog.findChild(QTableWidget) - assert ( - table is not None and table.item(0, 9).text().find("完整评论正文") >= 0 + assert table is not None and "完整评论正文" in table.item(0, 6).text() + assert "{" not in " ".join( + table.item(0, column).text() for column in range(table.columnCount()) ) table.item(0, 0).setCheckState(Qt.CheckState.Checked) return QDialog.DialogCode.Accepted @@ -207,7 +207,7 @@ class OnboardingUiTest(unittest.TestCase): "account": "main", "mode": "selected", "selected": [], - "all_pages": True, + "refresh_interval": 3600, "items": [ { "aweme_id": "700", @@ -215,6 +215,7 @@ class OnboardingUiTest(unittest.TestCase): "create_time": 123, "statistics": {"collect_count": 8}, "cover": "https://example.invalid/full.jpg", + "updated": 456, "business": {"aweme_id": "700", "desc": "完整作品描述"}, } ], @@ -222,7 +223,10 @@ class OnboardingUiTest(unittest.TestCase): def choose(dialog): table = dialog.findChild(QTableWidget) - assert table is not None and "完整作品描述" in table.item(0, 6).text() + assert table is not None and "完整作品描述" in table.item(0, 2).text() + assert "{" not in " ".join( + table.item(0, column).text() for column in range(table.columnCount()) + ) table.item(0, 0).setCheckState(Qt.CheckState.Checked) all_mode = next( box @@ -238,7 +242,13 @@ class OnboardingUiTest(unittest.TestCase): ): self.window.works_dialog(payload) send.assert_called_once_with( - "work_filter", {"id": "main", "mode": "selected", "ids": ["700"]} + "work_filter", + { + "id": "main", + "mode": "selected", + "ids": ["700"], + "refresh_interval": 3600, + }, ) def test_anonymous_profile_is_never_bound(self):