diff --git a/AGENTS.md b/AGENTS.md index 845c064..9e08c14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,11 @@ ## 产出目标 - 在src中输出自动化处理的python脚本,要求每个功能的脚本可独立运行 - 能用的功能、函数可使用公共包来实现,不需要独立实现 + +## Windows 远程部署与连接 +- 优先使用项目级 `.pi/mcp.json` 中的 `windows-ui` MCP 操作 Windows 主机;该连接用于启动、停止、查看和人工验收 Windows 应用。 +- Windows MCP 不满足需求或批量命令明显不便时,改用 SSH 执行文件传输、构建、安装和日志检查;连接地址、账号和密钥只从本机 SSH 配置读取,不写入代码、安装包或文档。 +- Windows 原生构建必须在 Windows x64 主机执行 `build_windows.py`,不要把 Linux 构建产物伪装成 Windows 安装包。 +- 部署顺序:先在本地完成测试,再将源码或构建产物传到 Windows;执行构建/安装后通过 Windows MCP 做 GUI 冒烟和人工验收。 +- 远程操作前先确认目标程序当前状态;不要自动登录、恢复业务规则或执行真实关注/私信等写操作。登录状态丢失时停止并交给用户登录。 +- 远程部署记录写入 `docs/YYYY-MM-DD-HH-II-.md`,记录连接方式、执行命令、产物路径和验证结果,但不记录密码、Cookie、UID 或登录目录内容。 diff --git a/docs/2026-09-07-17-04-works-card-cache.md b/docs/2026-09-07-17-04-works-card-cache.md new file mode 100644 index 0000000..84f141e --- /dev/null +++ b/docs/2026-09-07-17-04-works-card-cache.md @@ -0,0 +1,26 @@ +# 作品监控 CARD 列表与封面本地缓存 + +## 调研记录 + +- 原作品监控窗口位于 `src/accounts_app.py::Window.works_dialog`,原实现使用 `QTableWidget` 展示 10 列数据。 +- 保存逻辑依赖勾选作品 ID、监控模式和刷新间隔;这部分行为保持不变。 +- 作品数据中的 `cover` 是平台返回的 HTTPS 图片地址,作品缓存数据本身保存在 SQLite 中,但封面此前每次打开窗口都没有本地文件缓存。 +- 当前 UI 已有 `QNetworkAccessManager`,可复用其网络请求能力;不新增运行依赖。 + +## 最终方案 + +- 用 `QScrollArea + QGridLayout` 展示 CARD,固定 3 列,因此每行最多 3 个卡片。 +- 每张卡片采用左侧 132×132 封面、右侧作品描述、发布时间、作品 ID、点赞/评论/收藏/分享、本地更新时间和监控勾选框的布局。 +- 封面缓存写入数据根目录下的 `work_covers/`,文件名由“账号 ID + 作品 ID”哈希生成,避免直接使用外部字符串作为文件名。 +- 打开列表时优先读取本地图片;只有缓存不存在且 URL 为允许的抖音图片 HTTPS 域名时才发起请求。成功下载后先写临时文件,再原子替换缓存文件。 +- 保留不可见的原选择模型用于复用既有保存逻辑和兼容原有离线 UI 回归测试;用户可见区域不再显示表格。 + +## 验证 + +- `.venv/bin/python -m unittest discover -s src -v`:56 个测试通过。 +- `python -m py_compile src/accounts_app.py`:通过。 +- offscreen Qt 下完成作品卡片窗口构造 smoke check。 + +## 安装说明 + +无需新增依赖,继续按项目现有方式安装 `requirements-desktop.txt` 即可。 diff --git a/docs/2026-09-07-17-45-windows-remote-deploy-connection.md b/docs/2026-09-07-17-45-windows-remote-deploy-connection.md new file mode 100644 index 0000000..efefb84 --- /dev/null +++ b/docs/2026-09-07-17-45-windows-remote-deploy-connection.md @@ -0,0 +1,38 @@ +# Windows 远程部署连接方案 + +## 参考来源 + +参考 `/home/rogee/Workspace/windows/AGENTS.md` 与其 `.mcp.json`:Windows 操作优先使用 Windows MCP;MCP 不满足需求或批量命令不便时使用 SSH。 + +## 当前项目配置 + +项目级 `.pi/mcp.json` 增加 `windows-ui`: + +```json +{ + "url": "http://10.1.1.101:8765/mcp", + "excludeTools": ["screenshot"] +} +``` + +该配置只用于当前 Pi 项目,不写入 Windows 安装包。MCP 服务不可用时,不修改 MCP 配置来绕过问题,按本机 SSH 配置选择目标主机。 + +## 部署流程 + +1. 本地运行测试并检查工作区差异。 +2. Windows MCP 用于启动、停止、查看程序和人工 GUI 验收。 +3. SSH 用于源码/构建产物传输、Windows 原生构建、安装和日志检查。 +4. Windows x64 主机上执行 `build_windows.py`,生成 `release/DouyinAccounts-0.1.8-Windows-x64-Setup.exe`。 +5. 安装后通过 Windows MCP 做离线启动冒烟和界面验收;不自动登录账号、不恢复业务、不执行关注或私信。 +6. 记录产物路径、SHA256 和验证结果,不记录密码、Cookie、UID 或登录目录内容。 + +## 空白 Windows 环境 + +```powershell +py -3.12 -m venv .venv +.\\.venv\\Scripts\\python.exe -m pip install -r requirements-build-windows.txt +.\\.venv\\Scripts\\python.exe -m unittest discover -s src -v +.\\.venv\\Scripts\\python.exe build_windows.py +``` + +普通用户只运行生成的安装包,不需要 Python、Qt、Patchright 或额外下载浏览器。 diff --git a/docs/2026-09-07-17-59-windows-install-card-cache.md b/docs/2026-09-07-17-59-windows-install-card-cache.md new file mode 100644 index 0000000..5608658 --- /dev/null +++ b/docs/2026-09-07-17-59-windows-install-card-cache.md @@ -0,0 +1,44 @@ +# Windows 安装记录:作品 CARD 与封面缓存 + +## 目标 + +将当前 `src/accounts_app.py` 的作品 CARD 列表和封面本机缓存改动安装到 Windows x64 主机。远程 MCP 当前会话未加载,按项目规则使用 SSH fallback。 + +## 连接与冲突处理 + +- 目标:Windows 10 x64,SSH 目标 `10.1.1.101`,账号和密钥来自本机 SSH 配置/授权环境。 +- 安装前发现 `DouyinAccounts.exe` 正在运行,已关闭其进程树;没有删除数据目录,也没有主动关闭或修改账号登录数据。 + +## 构建 + +- Windows 构建目录:`C:\Users\rogee\douyin-pc-build-20260906` +- 同步文件:`src/accounts_app.py` +- Windows 单元测试:56 个通过。 +- 原生构建:`build_windows.py` 成功,Inno Setup 编译耗时约 312 秒。 +- 安装包:`release/DouyinAccounts-0.1.8-Windows-x64-Setup.exe` +- 安装包 SHA256:`6CA36F4388AB6D5DF7551E467E94B6FB27C1CCF353D974DAA2058661EB3D1A69` + +## 安装与验证 + +使用静默参数 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS` 安装完成,未自动启动业务应用。 + +安装路径:`C:\Users\Rogee\AppData\Local\Programs\DouyinAccounts\DouyinAccounts.exe` + +安装后的可执行文件与 Windows 构建目录中的 dist 可执行文件 SHA256 一致: + +`386AD6F7E6FA2A2C2811E4AD171E06698799D8584F5D6B3FFF01EFE9576B1790` + +已执行安装包自带离线冒烟测试,结果: + +```json +{ + "qt": true, + "core": true, + "sqlite": true, + "patchright": true, + "accounts": 0, + "real_write_actions": 0 +} +``` + +本次未启动真实业务窗口,因此没有触发登录、作品刷新、关注或私信操作;登录状态保持不变。 diff --git a/docs/2026-09-07-18-27-card-layout-no-restore-bubble.md b/docs/2026-09-07-18-27-card-layout-no-restore-bubble.md new file mode 100644 index 0000000..b393d21 --- /dev/null +++ b/docs/2026-09-07-18-27-card-layout-no-restore-bubble.md @@ -0,0 +1,23 @@ +# CARD 信息布局与 Chrome 恢复页面提示 + +## 修改 + +- CARD 右侧信息顺序调整为: + - `ID:xxx` + - 作品描述,最多展示 2 行,完整内容保留在 tooltip + - `点赞 · 评论 · 收藏 · 分享` + - `发布时间:xxx` + - `更新时间:xxx` +- 封面改为左侧 132×158 全高展示,CARD 外层不再预留图片 padding。 +- 浏览器启动参数增加 `--disable-session-crashed-bubble`,禁止 Chromium 弹出“恢复页面/未正常关闭”提示;不修改登录目录中的 Cookie 或历史数据。 + +## 验证与部署 + +- 本地测试:56 个测试通过。 +- Windows 原生测试:56 个测试通过。 +- Windows x64 原生构建成功。 +- 安装前关闭了正在运行的 `DouyinAccounts.exe` 进程树。 +- 已静默更新到:`C:\Users\Rogee\AppData\Local\Programs\DouyinAccounts\DouyinAccounts.exe` +- 安装后 dist 与已安装可执行文件 SHA256 一致: + `6EEC024995F8EBE17CC71C007F5E6B40CC275...` +- 安装包自带离线冒烟测试通过,Qt、核心、SQLite、Patchright 均正常,未执行真实业务写操作。 diff --git a/docs/2026-09-07-19-15-card-interaction-events.md b/docs/2026-09-07-19-15-card-interaction-events.md new file mode 100644 index 0000000..ea1b8d1 --- /dev/null +++ b/docs/2026-09-07-19-15-card-interaction-events.md @@ -0,0 +1,21 @@ +# CARD 互动事件分开展示 + +## 修改 + +作品卡片右侧互动数据由单行改为分行显示: + +- `点赞:xxx` +- `评论:xxx` +- `收藏:xxx` +- `分享:xxx` + +描述仍支持最多两行折叠,并提供“展开/收起”按钮查看全文;封面随卡片高度保持左侧全高。 + +## Windows 部署验证 + +- Windows 原生单元测试:56 个通过。 +- Windows 原生构建成功。 +- 已静默安装到 `C:\Users\Rogee\AppData\Local\Programs\DouyinAccounts`。 +- 安装后的 dist 与已安装可执行文件 SHA256 一致:`5BC12B0D9584998785F9FA502E80060492D97...` +- 安装包离线冒烟测试通过,未执行真实业务写操作。 +- 当前应用未运行。 diff --git a/src/account_browser.py b/src/account_browser.py index 9ce93ee..7bec38c 100644 --- a/src/account_browser.py +++ b/src/account_browser.py @@ -256,6 +256,7 @@ class BrowserManager: "--remote-debugging-address=127.0.0.1", "--no-first-run", "--no-default-browser-check", + "--disable-session-crashed-bubble", f"--fingerprint={expected['fingerprint_seed']}", *expected["args"], "https://www.douyin.com/", diff --git a/src/accounts_app.py b/src/accounts_app.py index a01d160..c71c793 100644 --- a/src/accounts_app.py +++ b/src/accounts_app.py @@ -2,6 +2,7 @@ import argparse import asyncio +import hashlib import json import queue import sys @@ -21,6 +22,8 @@ from PySide6.QtWidgets import ( QDialogButtonBox, QFileDialog, QFormLayout, + QFrame, + QGridLayout, QHBoxLayout, QHeaderView, QLabel, @@ -29,10 +32,13 @@ from PySide6.QtWidgets import ( QMessageBox, QPlainTextEdit, QPushButton, + QScrollArea, + QSizePolicy, QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, + QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, @@ -45,6 +51,154 @@ from account_log import UI_LIMIT, DailyLog from account_store import decode, validate_rule +IMAGE_HOST_SUFFIXES = ( + "douyinpic.com", + "byteimg.com", + "douyincdn.com", + "ibytedtos.com", +) + + +def allowed_image_url(value): + url = QUrl(value or "") + host = url.host().lower() + return bool(value) and url.scheme() == "https" and any( + host.endswith("." + suffix) for suffix in IMAGE_HOST_SUFFIXES + ) + + +class WorkCard(QFrame): + def __init__(self, item, checked=False, parent=None): + super().__init__(parent) + self.setObjectName("workCard") + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setMinimumHeight(158) + self.check_box = QCheckBox("监控") + self.check_box.setChecked(checked) + self.cover_label = QLabel("暂无封面") + self.cover_label.setObjectName("workCover") + self.cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.cover_label.setFixedWidth(132) + self.cover_label.setMinimumHeight(158) + self.cover_label.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) + self.cover_label.setStyleSheet( + "background: #f3f4f6; color: #8a8f98; border-radius: 4px;" + ) + self._cover_pixmap = QPixmap() + description = item.get("desc") or "未填写作品描述" + self.description_label = QLabel(description) + self.description_label.setWordWrap(True) + self.description_label.setToolTip(description) + self._description = description + self._description_collapsed_height = ( + self.description_label.fontMetrics().lineSpacing() * 2 + 4 + ) + self._description_expanded = False + self.description_toggle = QToolButton() + self.description_toggle.setText("展开") + self.description_toggle.setAutoRaise(True) + self.description_toggle.setStyleSheet("color: #1677ff; border: 0; padding: 0;") + self.description_toggle.clicked.connect(self.toggle_description) + statistics = item.get("statistics") or {} + stats = QLabel( + "\n".join( + [ + "点赞:" + str(statistics.get("digg_count", 0)), + "评论:" + str(statistics.get("comment_count", 0)), + "收藏:" + str(statistics.get("collect_count", 0)), + "分享:" + str(statistics.get("share_count", 0)), + ] + ) + ) + times = QLabel( + "发布时间:{}\n更新时间:{}".format( + date_text(item.get("create_time")), + date_text(item.get("updated")), + ) + ) + id_label = QLabel("ID:" + str(item["aweme_id"])) + for label in (id_label, stats, times): + label.setStyleSheet("color: #68707d;") + label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + info = QVBoxLayout() + info.setContentsMargins(10, 8, 10, 8) + header = QHBoxLayout() + header.addWidget(id_label) + header.addStretch() + header.addWidget(self.check_box) + info.addLayout(header) + info.addWidget(self.description_label) + description_actions = QHBoxLayout() + description_actions.addStretch() + description_actions.addWidget(self.description_toggle) + info.addLayout(description_actions) + info.addWidget(stats) + info.addWidget(times) + info.addStretch() + self._refresh_description() + card_layout = QHBoxLayout(self) + card_layout.setContentsMargins(0, 0, 0, 0) + card_layout.setSpacing(0) + card_layout.addWidget(self.cover_label) + card_layout.addLayout(info, 1) + self.setStyleSheet( + "#workCard { border: 1px solid #d9dde5; border-radius: 6px; }" + ) + + def toggle_description(self): + self._description_expanded = not self._description_expanded + self.description_toggle.setText( + "收起" if self._description_expanded else "展开" + ) + self._refresh_description() + + def _refresh_description(self): + width = max(1, self.description_label.width()) + full_height = self.description_label.fontMetrics().boundingRect( + 0, + 0, + width, + 10000, + Qt.TextFlag.TextWordWrap, + self._description, + ).height() + expandable = full_height > self._description_collapsed_height + self.description_toggle.setVisible(expandable) + if not expandable: + self._description_expanded = False + self.description_toggle.setText("展开") + self.description_label.setMaximumHeight( + 16777215 + if self._description_expanded + else self._description_collapsed_height + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._refresh_description() + self._render_cover() + + def set_cover(self, pixmap): + if pixmap.isNull(): + return + self._cover_pixmap = pixmap + self.cover_label.setText("") + self._render_cover() + + def _render_cover(self): + if self._cover_pixmap.isNull() or self.cover_label.size().isEmpty(): + return + self.cover_label.setPixmap( + self._cover_pixmap.scaled( + self.cover_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + def date_text(value): try: stamp = float(value) @@ -198,6 +352,7 @@ class Window(QMainWindow): self.signature = None self.icons = {} self.avatar_pending = set() + self.work_cover_cache = Path(self.backend.root) / "work_covers" self.network = QNetworkAccessManager(self) self.setWindowTitle("抖音账号助手 · 自动操作默认关闭") self.resize(1260, 780) @@ -572,7 +727,7 @@ class Window(QMainWindow): all_mode.setChecked(payload["mode"] == "all") layout.addWidget(all_mode) note = QLabel( - "取消上方选项后,只监控表格中勾选作品的点赞、评论、收藏等互动;关注通知不受作品筛选。完整平台数据只保存在本地数据库中。" + "取消上方选项后,只监控下方勾选作品的点赞、评论、收藏等互动;关注通知不受作品筛选。封面首次加载后保存在本机,后续打开优先读取缓存。" ) note.setWordWrap(True) layout.addWidget(note) @@ -591,7 +746,10 @@ class Window(QMainWindow): ) schedule.addStretch() layout.addLayout(schedule) - table = QTableWidget(len(items), 10) + + # Keep the existing selection model so saving behavior stays unchanged; + # only the visible presentation is now cards. + table = QTableWidget(len(items), 10, win) table.setHorizontalHeaderLabels( [ "选择", @@ -606,6 +764,22 @@ class Window(QMainWindow): "本地更新时间", ] ) + table.hide() + cards = [] + card_area = QScrollArea() + card_area.setWidgetResizable(True) + card_area.setFrameShape(QFrame.Shape.NoFrame) + card_host = QWidget() + card_grid = QGridLayout(card_host) + card_grid.setContentsMargins(4, 4, 4, 4) + card_grid.setHorizontalSpacing(12) + card_grid.setVerticalSpacing(12) + for column in range(3): + card_grid.setColumnStretch(column, 1) + card_area.setWidget(card_host) + layout.addWidget(card_area, 1) + + cover_cards = {} for row, item in enumerate(items): check = QTableWidgetItem() check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) @@ -616,29 +790,107 @@ class Window(QMainWindow): ) check.setData(Qt.ItemDataRole.UserRole, item["aweme_id"]) table.setItem(row, 0, check) - statistics = item["statistics"] + statistics = item.get("statistics") or {} values = [ - date_text(item["create_time"]), - item["desc"] or "未填写作品描述", + date_text(item.get("create_time")), + item.get("desc") or "未填写作品描述", item["aweme_id"], statistics.get("digg_count", 0), statistics.get("comment_count", 0), statistics.get("collect_count", 0), statistics.get("share_count", 0), - item["cover"] or "无封面地址", + item.get("cover") or "无封面地址", date_text(item.get("updated")), ] for column, value in enumerate(values, 1): cell = QTableWidgetItem(str(value)) cell.setToolTip(str(value)) table.setItem(row, column, cell) - table.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeMode.ResizeToContents - ) - table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + + card = WorkCard(item, check.checkState() == Qt.CheckState.Checked) + cards.append(card) + card_grid.addWidget(card, row // 3, row % 3) + cover_cards.setdefault(item.get("aweme_id", ""), []).append(card) + + def sync_card(item): + if item.column() != 0 or not 0 <= item.row() < len(cards): + return + checked = item.checkState() == Qt.CheckState.Checked + if cards[item.row()].check_box.isChecked() != checked: + cards[item.row()].check_box.setChecked(checked) + + def sync_table(checked, row): + item = table.item(row, 0) + if item is not None: + state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + if item.checkState() != state: + item.setCheckState(state) + + table.itemChanged.connect(sync_card) + for row, card in enumerate(cards): + card.check_box.toggled.connect(lambda checked, row=row: sync_table(checked, row)) + + def load_cover(item): + ident = item["aweme_id"] + cache_path = self.work_cover_cache / ( + hashlib.sha256( + f"{payload['account']}:{ident}".encode() + ).hexdigest() + + ".img" + ) + pixmap = QPixmap() + try: + if cache_path.is_file(): + pixmap.loadFromData(cache_path.read_bytes()) + except OSError: + pass + if not pixmap.isNull(): + for card in cover_cards.get(ident, []): + card.set_cover(pixmap) + return + url = item.get("cover") or "" + if not allowed_image_url(url): + return + request = QNetworkRequest(QUrl(url)) + request.setTransferTimeout(10000) + request.setAttribute( + QNetworkRequest.Attribute.RedirectPolicyAttribute, + QNetworkRequest.RedirectPolicy.ManualRedirectPolicy, + ) + reply = self.network.get(request) + reply.downloadProgress.connect( + lambda received, total: ( + reply.abort() + if received > 2_000_000 or total > 2_000_000 + else None + ) + ) + + def loaded(): + raw = bytes(reply.readAll()) + image = QPixmap() + if reply.error() == reply.NetworkError.NoError: + image.loadFromData(raw) + if not image.isNull(): + try: + self.work_cover_cache.mkdir(parents=True, exist_ok=True) + temp = cache_path.with_name(cache_path.name + ".tmp") + temp.write_bytes(raw) + temp.replace(cache_path) + except OSError: + pass + for card in cover_cards.get(ident, []): + card.set_cover(image) + reply.deleteLater() + + reply.finished.connect(loaded) + + for item in items: + load_cover(item) table.setDisabled(all_mode.isChecked()) + card_area.setDisabled(all_mode.isChecked()) all_mode.toggled.connect(table.setDisabled) - layout.addWidget(table) + all_mode.toggled.connect(card_area.setDisabled) controls = QHBoxLayout() select_all = QPushButton("全选") clear = QPushButton("清空选择") @@ -955,25 +1207,14 @@ class Window(QMainWindow): self.send("resolve", {"task": task["id"], "succeeded": succeeded}) def avatar(self, account): - url = QUrl(account.get("avatar", "")) - host = url.host().lower() - allowed = any( - host.endswith("." + suffix) - for suffix in ( - "douyinpic.com", - "byteimg.com", - "douyincdn.com", - "ibytedtos.com", - ) - ) key = account["avatar"] if ( key and key not in self.icons and key not in self.avatar_pending - and allowed - and url.scheme() == "https" + and allowed_image_url(key) ): + url = QUrl(key) self.avatar_pending.add(key) request = QNetworkRequest(url) request.setTransferTimeout(10000) diff --git a/src/test_browser_matrix.py b/src/test_browser_matrix.py index bb4b0ba..f83ab98 100644 --- a/src/test_browser_matrix.py +++ b/src/test_browser_matrix.py @@ -164,6 +164,7 @@ async def matrix(chrome): real_popen = subprocess.Popen def offline_launch(args, **kwargs): + assert "--disable-session-crashed-bubble" in args # Only this test replaces the home URL and disables background network traffic. return real_popen( [