Files
douyin-pc/src/accounts_app.py
T

2170 lines
86 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""中文 Windows desktop; business core runs in a dedicated async thread."""
import argparse
import asyncio
import hashlib
import json
import queue
import sys
import tempfile
import uuid
from contextlib import suppress
from datetime import datetime, timezone
from pathlib import Path
from patchright.async_api import async_playwright
from PySide6.QtCore import QEvent, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QSplitter,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from account_browser import AppLock, browser_executable, default_root, protect_root
from account_engine import Engine
from account_log import UI_LIMIT, DailyLog
from account_store import NOTICE_KIND_LABELS, 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)
except (TypeError, ValueError):
return "未知时间"
if stamp > 10_000_000_000:
stamp /= 1000
try:
return (
datetime.fromtimestamp(stamp, timezone.utc)
.astimezone()
.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)
logs_ready = Signal(list)
data_ready = Signal(str, object)
stopped = Signal()
def __init__(self, root):
super().__init__()
self.root = root
self.commands = queue.Queue()
self.command_tasks = set()
def post(self, name, data=None):
self.commands.put((name, data or {}))
async def serve(self):
async with async_playwright() as playwright:
engine = Engine(self.root, playwright)
cursor = 0
async def handle(name, data):
try:
result = await engine.command(name, data)
if result is not None:
if isinstance(result, dict) and data.get("_request_id"):
result = {
**result,
"_request_id": data["_request_id"],
}
self.data_ready.emit(name, result)
self.response.emit(name, True, "操作完成")
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - UI boundary
# Only explicitly safe human-facing errors; never emit CDP stack/URLs.
message = (
str(exc)
if isinstance(exc, (ValueError, RuntimeError))
else "操作失败,请检查配置与账号状态"
)
self.response.emit(name, False, message)
finally:
self.command_tasks.discard(asyncio.current_task())
try:
while True:
self.snapshot_ready.emit(engine.snapshot())
if engine.audit.sequence != cursor:
self.logs_ready.emit(engine.audit.since(cursor))
cursor = engine.audit.sequence
try:
name, data = self.commands.get_nowait()
except queue.Empty:
await asyncio.sleep(0.3)
continue
if name == "quit":
break
task = asyncio.create_task(handle(name, data))
self.command_tasks.add(task)
finally:
for task in list(self.command_tasks):
task.cancel()
if self.command_tasks:
await asyncio.gather(*self.command_tasks, return_exceptions=True)
await engine.shutdown()
def run(self):
try:
asyncio.run(self.serve())
except Exception as exc: # noqa: BLE001 - thread boundary
audit = DailyLog(self.root)
cursor = audit.sequence
audit.record(
"核心异常",
"业务核心退出,请检查数据库、磁盘权限或运行环境;不会自动登录",
level=40,
异常类型=type(exc).__name__,
)
self.logs_ready.emit(audit.since(cursor))
audit.close()
self.response.emit(
"startup",
False,
"核心启动失败,请检查数据目录、数据库和运行环境;未执行任何自动登录",
)
finally:
self.stopped.emit()
def dialog(title, parent):
win = QDialog(parent)
win.setWindowTitle(title)
win.resize(540, 300)
form = QFormLayout(win)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.accepted.connect(win.accept)
buttons.rejected.connect(win.reject)
return win, form, buttons
class AccountCard(QFrame):
selected = Signal(object)
context_requested = Signal(object, object)
checked = Signal(str, bool)
expand_requested = Signal()
def __init__(
self,
account_id,
title,
meta,
status,
color,
checkable=False,
expandable=False,
parent=None,
):
super().__init__(parent)
self.account_id = account_id
self.setObjectName("accountCard")
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setMinimumHeight(56)
self.setStyleSheet(
"#accountCard { border: 1px solid #d9dde5; border-radius: 7px; background: white; }"
'#accountCard[selected="true"] { border: 2px solid #1677ff; }'
)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 6, 10, 6)
layout.setSpacing(6)
self.toggle_button = QToolButton()
self.toggle_button.setAutoRaise(True)
self.toggle_button.setFixedWidth(22)
self.toggle_button.setToolTip("展开/折叠小号")
self.toggle_button.clicked.connect(self.expand_requested)
layout.addWidget(self.toggle_button)
self.set_expandable(expandable)
self.check_box = QCheckBox() if checkable else None
if self.check_box is not None:
self.check_box.setToolTip("勾选后可批量启动该大号")
self.check_box.toggled.connect(
lambda checked: self.checked.emit(self.account_id, checked)
)
layout.addWidget(self.check_box)
self.status_dot = QLabel("●")
self.status_dot.setStyleSheet(f"color: {color}; font-size: 15px;")
layout.addWidget(self.status_dot)
text_layout = QVBoxLayout()
text_layout.setSpacing(0)
self.title_label = QLabel(title)
self.title_label.setStyleSheet("font-weight: 600;")
text_layout.addWidget(self.title_label)
self.meta_label = QLabel(meta)
self.meta_label.setStyleSheet("color: #68707d;")
text_layout.addWidget(self.meta_label)
self.status_label = QLabel(status)
self.status_label.setStyleSheet("color: #68707d;")
text_layout.addWidget(self.status_label)
layout.addLayout(text_layout, 1)
self.setToolTip("\n".join((title, meta, status)))
self._install_event_filters()
def _install_event_filters(self):
for child in self.findChildren(QWidget):
child.installEventFilter(self)
def set_expandable(self, expandable):
self.toggle_button.setVisible(expandable)
if not expandable:
self.toggle_button.setArrowType(Qt.ArrowType.NoArrow)
def set_expanded(self, expanded):
self.toggle_button.setChecked(expanded)
self.toggle_button.setArrowType(
Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow
)
def set_selected(self, selected):
self.setProperty("selected", "true" if selected else "false")
self.style().unpolish(self)
self.style().polish(self)
def eventFilter(self, watched, event):
if event.type() == QEvent.Type.MouseButtonPress:
if event.button() == Qt.MouseButton.RightButton:
self.context_requested.emit(
self.account_id, event.globalPosition().toPoint()
)
return True
if (
event.button() == Qt.MouseButton.LeftButton
and watched not in (self.check_box, self.toggle_button)
and self.account_id is not None
):
self.selected.emit(self.account_id)
return False
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.RightButton:
self.context_requested.emit(
self.account_id, event.globalPosition().toPoint()
)
elif (
event.button() == Qt.MouseButton.LeftButton and self.account_id is not None
):
self.selected.emit(self.account_id)
super().mousePressEvent(event)
def contextMenuEvent(self, event):
self.context_requested.emit(self.account_id, event.globalPos())
class AccountTreeNode(QWidget):
def __init__(self, card, parent=None, parent_node=None):
super().__init__(parent)
self.card = card
self.parent_node = parent_node
self.expanded = True
self.children_host = QWidget()
self.children_host.setObjectName("accountTreeChildren")
self.children_host.setStyleSheet(
"#accountTreeChildren { border-left: 1px solid #d9dde5; }"
)
self.children_layout = QVBoxLayout(self.children_host)
self.children_layout.setContentsMargins(22, 3, 0, 0)
self.children_layout.setSpacing(4)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
layout.addWidget(card)
layout.addWidget(self.children_host)
self.children_host.hide()
card.expand_requested.connect(self.toggle)
def add_child(self, node):
node.parent_node = self
self.children_layout.addWidget(node)
self.card.set_expandable(True)
self.set_expanded(True)
def set_expanded(self, expanded):
self.expanded = expanded
has_children = self.children_layout.count() > 0
self.children_host.setVisible(has_children and expanded)
self.card.set_expandable(has_children)
if has_children:
self.card.set_expanded(expanded)
def toggle(self):
self.set_expanded(not self.expanded)
def reveal(self):
parent = self.parent_node
while parent is not None:
parent.set_expanded(True)
parent = parent.parent_node
class AccountCardTree(QScrollArea):
selection_changed = Signal()
context_menu_requested = Signal(object, object)
def __init__(self, parent=None):
super().__init__(parent)
self.setWidgetResizable(True)
self.setFrameShape(QFrame.Shape.NoFrame)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.host = QWidget()
self.layout_box = QVBoxLayout(self.host)
self.layout_box.setContentsMargins(2, 2, 8, 2)
self.layout_box.setSpacing(6)
self.layout_box.addStretch()
self.setWidget(self.host)
self.cards = {}
self.groups = {}
self.nodes = {}
self._selected_id = None
self.viewport().installEventFilter(self)
def _clear(self):
while self.layout_box.count():
item = self.layout_box.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self.layout_box.addStretch()
def _add_root(self, node):
self.layout_box.insertWidget(self.layout_box.count() - 1, node)
def _connect(self, card):
card.selected.connect(self._select)
card.context_requested.connect(self.context_menu_requested)
def set_accounts(self, accounts, display, checked, selected_id):
self._clear()
self.cards = {}
self.groups = {}
self.nodes = {}
self._selected_id = selected_id
main_nodes = {}
mains = [account for account in accounts if account["role"] == "main"]
workers = [account for account in accounts if account["role"] == "worker"]
for account in mains:
card = AccountCard(
account["id"],
**display[account["id"]],
checkable=True,
parent=self.host,
)
if card.check_box is not None:
card.check_box.setChecked(account["id"] in checked)
self._connect(card)
node = AccountTreeNode(card, self.host)
self._add_root(node)
self.cards[account["id"]] = card
self.groups[account["id"]] = card
self.nodes[account["id"]] = node
main_nodes[account["id"]] = node
unassigned_node = None
for account in workers:
parent_node = main_nodes.get(account.get("owner"))
if parent_node is None:
if unassigned_node is None:
group_card = AccountCard(
None,
"未分配小号",
"没有归属大号",
"状态:执行入口关闭",
"#68707d",
expandable=True,
parent=self.host,
)
unassigned_node = AccountTreeNode(group_card, self.host)
self._add_root(unassigned_node)
parent_node = unassigned_node
card = AccountCard(
account["id"],
**display[account["id"]],
parent=parent_node.children_host,
)
self._connect(card)
node = AccountTreeNode(card, parent_node.children_host, parent_node)
parent_node.add_child(node)
self.cards[account["id"]] = card
self.nodes[account["id"]] = node
for card in self.cards.values():
card.set_selected(card.account_id == self._selected_id)
if self._selected_id in self.nodes:
self.nodes[self._selected_id].reveal()
def _select(self, account_id):
if account_id not in self.cards:
return
self._selected_id = account_id
self.nodes[account_id].reveal()
for card in self.cards.values():
card.set_selected(card.account_id == account_id)
self.selection_changed.emit()
def selected_id(self):
return self._selected_id
def set_current_id(self, account_id):
self._select(account_id)
def checked_groups(self):
return [
account_id
for account_id, card in self.groups.items()
if card.check_box is not None and card.check_box.isChecked()
]
def eventFilter(self, watched, event):
if watched is self.viewport() and event.type() == QEvent.Type.ContextMenu:
self.context_menu_requested.emit(None, event.globalPos())
return True
return super().eventFilter(watched, event)
class Window(QMainWindow):
def __init__(self, backend):
super().__init__()
self.backend = backend
self.data = {
"accounts": [],
"deleted_accounts": [],
"tasks": [],
"selected": [],
"active": [],
"browser": {},
"browser_overrides": {},
}
self.selection_loaded = False
self.allow_close = False
self.quitting = False
self.signature = None
self.icons = {}
self.avatar_pending = set()
self.latest_requests = {}
self.latest_global_requests = {}
self.detail_account_id = None
self.history_payload = None
self.works_payload = None
self.exception_tasks = []
self.exception_names = {}
self.work_cover_cache = Path(self.backend.root) / "work_covers"
self.network = QNetworkAccessManager(self)
self.setWindowTitle("抖音账号助手 · 自动操作默认关闭")
self.resize(1260, 860)
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
note = QLabel(
"左侧账号树只显示别名、名称和状态;点击账号查看详情,账号操作请使用右键菜单。"
"恢复选择不会启动业务,启动前会先检查旧 pending 任务。"
)
note.setWordWrap(True)
layout.addWidget(note)
row = QHBoxLayout()
layout.addLayout(row)
self.buttons = []
for text, handler in [
("添加大号", lambda: self.add("main")),
("添加小号", lambda: self.add("worker")),
("恢复上次选择", self.restore_selection),
("启动勾选组", self.start_groups),
("停止全部", lambda: self.send("stop")),
]:
button = self.button(row, text, handler)
if text == "停止全部":
self.stop_button = button
self.exception_button = QPushButton("异常任务")
self.exception_button.clicked.connect(self.show_exception_tasks)
row.addWidget(self.exception_button)
self.button(row, "设置", lambda: self.browser_settings(False))
row.addStretch()
self.workspace_stack = QStackedWidget()
accounts = QWidget()
account_layout = QHBoxLayout(accounts)
splitter = QSplitter(Qt.Orientation.Horizontal)
account_layout.addWidget(splitter)
left = QWidget()
left_layout = QVBoxLayout(left)
self.card_table = AccountCardTree()
self.card_table.setMinimumWidth(320)
self.card_table.context_menu_requested.connect(self.account_menu)
self.card_table.selection_changed.connect(self.show_profile)
left_layout.addWidget(self.card_table)
left.setMinimumWidth(330)
splitter.addWidget(left)
right = QWidget()
right_layout = QVBoxLayout(right)
self.detail_title = QLabel("选择账号")
self.detail_title.setObjectName("accountDetailTitle")
right_layout.addWidget(self.detail_title)
self.profile_summary = QLabel("选择账号可查看自动获取的资料与统计")
self.profile_summary.setWordWrap(True)
right_layout.addWidget(self.profile_summary)
self.detail_tabs = QTabWidget()
current_tab = QWidget()
current_layout = QVBoxLayout(current_tab)
self.detail_task_summary = QLabel("当前任务:—")
self.detail_task_summary.setWordWrap(True)
current_layout.addWidget(self.detail_task_summary)
self.current_task_table = self._task_table(checkable=True)
current_layout.addWidget(self.current_task_table, 1)
self.cancel_pending_button = self.button(
current_layout, "取消所选 pending", self.cancel_selected_pending
)
history_tab = QWidget()
history_layout = QVBoxLayout(history_tab)
self.history_task_summary = QLabel("历史任务:—")
self.history_task_summary.setWordWrap(True)
history_layout.addWidget(self.history_task_summary)
self.history_task_table = self._task_table(checkable=False)
history_layout.addWidget(self.history_task_table, 1)
history_layout.addWidget(
QLabel("结果不确定仅作只读记录,不提供人工核定、重发、改派或状态修改。")
)
self.detail_tabs.addTab(current_tab, "当前任务")
self.detail_tabs.addTab(history_tab, "历史任务")
main_history_tab = QWidget()
main_history_layout = QVBoxLayout(main_history_tab)
self.history_info = QLabel(
"历史事件尚未同步;同步只保存本地数据,不标记已读,也不会自动创建任务。"
)
self.history_info.setWordWrap(True)
main_history_layout.addWidget(self.history_info)
history_controls = QHBoxLayout()
history_controls.addWidget(QLabel("事件类型"))
self.history_kind_filter = QComboBox()
self.history_kind_filter.addItem("全部类型", "")
self.history_kind_filter.currentIndexChanged.connect(self.filter_history)
history_controls.addWidget(self.history_kind_filter)
self.button(history_controls, "同步历史事件", self.history_events)
self.button(history_controls, "完整补齐", self.history_full)
self.button(
history_controls,
"提交选中事件",
self.confirm_history,
)
history_controls.addStretch()
main_history_layout.addLayout(history_controls)
history_select = QHBoxLayout()
self.button(
history_select,
"全选当前显示",
lambda: self.set_table_checks(self.history_table, Qt.CheckState.Checked),
)
self.button(
history_select,
"清空当前显示",
lambda: self.set_table_checks(self.history_table, Qt.CheckState.Unchecked),
)
history_select.addStretch()
main_history_layout.addLayout(history_select)
self.history_table = QTableWidget(0, 9)
self.history_table.setHorizontalHeaderLabels(
[
"选择",
"发生时间",
"事件类型",
"来源用户",
"作品名称",
"作品ID",
"评论或内容",
"通知ID",
"数据来源",
]
)
self.history_table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
)
self.history_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.history_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.ResizeToContents
)
self.history_table.horizontalHeader().setSectionResizeMode(
4, QHeaderView.ResizeMode.Stretch
)
main_history_layout.addWidget(self.history_table, 1)
main_works_tab = QWidget()
main_works_layout = QVBoxLayout(main_works_tab)
self.works_info = QLabel(
"作品缓存尚未同步;完整标记与覆盖数量以本地数据库为准。"
)
self.works_info.setWordWrap(True)
main_works_layout.addWidget(self.works_info)
works_controls = QHBoxLayout()
self.works_all_mode = QCheckBox("监控全部作品(包含以后发布的新作品)")
self.works_all_mode.toggled.connect(self._toggle_works_selection)
works_controls.addWidget(self.works_all_mode)
works_controls.addWidget(QLabel("自动刷新间隔"))
self.works_refresh_minutes = QSpinBox()
self.works_refresh_minutes.setRange(0, 10080)
self.works_refresh_minutes.setSpecialValueText("关闭")
self.works_refresh_minutes.setSuffix(" 分钟")
works_controls.addWidget(self.works_refresh_minutes)
self.button(works_controls, "刷新作品", self.fetch_works)
self.button(works_controls, "强制增量刷新", self.works_refresh)
self.button(works_controls, "完整补齐", self.works_full)
works_controls.addStretch()
main_works_layout.addLayout(works_controls)
main_works_layout.addWidget(
QLabel(
"取消全部作品后,只监控下方选择;封面缓存保存在本机,停止账号组后不定时请求。"
)
)
self.works_table = QTableWidget(0, 10, main_works_tab)
self._works_table_sync = None
self.works_table.hide()
self.works_card_area = QScrollArea()
self.works_card_area.setWidgetResizable(True)
self.works_card_area.setFrameShape(QFrame.Shape.NoFrame)
self.works_card_host = QWidget()
self.works_card_grid = QGridLayout(self.works_card_host)
self.works_card_grid.setContentsMargins(4, 4, 4, 4)
self.works_card_grid.setHorizontalSpacing(12)
self.works_card_grid.setVerticalSpacing(12)
for column in range(3):
self.works_card_grid.setColumnStretch(column, 1)
self.works_card_area.setWidget(self.works_card_host)
main_works_layout.addWidget(self.works_card_area, 1)
works_select = QHBoxLayout()
self.button(
works_select,
"全选",
lambda: self.set_table_checks(self.works_table, Qt.CheckState.Checked),
)
self.button(
works_select,
"清空选择",
lambda: self.set_table_checks(self.works_table, Qt.CheckState.Unchecked),
)
self.button(works_select, "保存监控范围", self.save_work_filter)
works_select.addStretch()
main_works_layout.addLayout(works_select)
self.detail_tabs.addTab(main_history_tab, "历史事件")
self.detail_tabs.addTab(main_works_tab, "作品列表")
right_layout.addWidget(self.detail_tabs, 1)
splitter.addWidget(right)
splitter.setStretchFactor(0, 2)
splitter.setStretchFactor(1, 5)
splitter.setSizes([390, 840])
self.account_page = accounts
self.workspace_stack.addWidget(accounts)
self.exception_page = self._build_exception_page()
self.workspace_stack.addWidget(self.exception_page)
logs = QWidget()
logs_layout = QVBoxLayout(logs)
self.log_info = QLabel("正在加载日志;按天保存完整记录,界面仅保留最近 2000 条")
self.log_info.setWordWrap(True)
logs_layout.addWidget(self.log_info)
controls = QHBoxLayout()
self.auto_scroll = QCheckBox("自动滚动到最新记录")
self.auto_scroll.setChecked(True)
controls.addWidget(self.auto_scroll)
open_logs = QPushButton("打开日志文件夹")
open_logs.clicked.connect(self.open_logs)
controls.addWidget(open_logs)
controls.addStretch()
logs_layout.addLayout(controls)
self.log = QPlainTextEdit()
self.log.setReadOnly(True)
self.log.setMaximumBlockCount(UI_LIMIT)
logs_layout.addWidget(self.log)
vertical = QSplitter(Qt.Orientation.Vertical)
vertical.addWidget(self.workspace_stack)
vertical.addWidget(logs)
vertical.setStretchFactor(0, 5)
vertical.setStretchFactor(1, 2)
vertical.setSizes([590, 180])
layout.addWidget(vertical, 1)
self.table = self.current_task_table
self.statusBar().showMessage("正在初始化;不会自动启动业务")
backend.snapshot_ready.connect(self.refresh)
backend.response.connect(self.response)
backend.logs_ready.connect(self.append_logs)
backend.data_ready.connect(self.command_data)
backend.stopped.connect(self.finished)
def button(self, layout, text, handler):
button = QPushButton(text)
button.clicked.connect(handler)
layout.addWidget(button)
self.buttons.append(button)
return button
def _task_table(self, checkable=False):
headers = (
[
"选择",
"任务",
"通知",
"来源大号",
"执行小号",
"目标 UID",
"动作",
"状态",
"结果说明",
"创建时间",
"开始时间",
"完成时间",
]
if checkable
else [
"任务",
"通知",
"来源大号",
"执行小号",
"目标 UID",
"动作",
"状态",
"结果说明",
"创建时间",
"开始时间",
"完成时间",
]
)
table = QTableWidget(0, len(headers))
table.setHorizontalHeaderLabels(headers)
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
table.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection
if checkable
else QAbstractItemView.SelectionMode.SingleSelection
)
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.ResizeToContents
)
table.horizontalHeader().setSectionResizeMode(
8 if checkable else 7, QHeaderView.ResizeMode.Stretch
)
return table
def _fill_task_table(self, table, tasks, names, checkable=False):
labels = {
"pending": "待执行",
"running": "执行中",
"succeeded": "成功",
"failed": "失败",
"unknown": "结果不确定",
"cancelled": "已取消",
}
table.setRowCount(len(tasks))
for row, task in enumerate(tasks):
if checkable:
first = QTableWidgetItem()
first.setData(Qt.ItemDataRole.UserRole, str(task["id"]))
first.setFlags(Qt.ItemFlag.ItemIsEnabled)
if task["status"] == "pending":
first.setFlags(first.flags() | Qt.ItemFlag.ItemIsUserCheckable)
first.setCheckState(Qt.CheckState.Unchecked)
else:
first = QTableWidgetItem(str(task["id"]))
first.setData(Qt.ItemDataRole.UserRole, str(task["id"]))
table.setItem(row, 0, first)
values = [
task["id"],
task["event"],
names.get(task["source"], task["source"]),
names.get(task["worker"], task["worker"]),
task["target"],
"关注" if task["action"] == "follow" else "私信",
labels.get(task["status"], task["status"]),
task_result_text(task["result"]),
date_text(task.get("created_at")),
date_text(task.get("started_at")),
date_text(task.get("finished_at")),
]
for column, value in enumerate(values, 1 if checkable else 0):
table.setItem(row, column, QTableWidgetItem(str(value)))
@staticmethod
def _sync_text(sync, label):
sync = sync or {}
status_key = str(sync.get("status") or "")
status = {
"complete": "完整",
"partial": "部分",
"failed": "失败",
"syncing": "同步中",
"not_started": "未同步",
}.get(status_key, status_key or "未同步")
text = f"{label}:{status} · 覆盖 {sync.get('coverage', 0)}"
if sync.get("last_success"):
text += f" · 最近成功 {date_text(sync['last_success'])}"
if sync.get("error"):
text += f" · {sync['error']}"
return text
def _clear_layout(self, layout):
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def _toggle_works_selection(self, checked):
self.works_table.setDisabled(checked)
self.works_card_area.setDisabled(checked)
def _build_exception_page(self):
page = QWidget()
layout = QVBoxLayout(page)
heading = QHBoxLayout()
heading.addWidget(QLabel("异常任务(仅显示 unknown,只读)"))
back = QPushButton("返回账号工作台")
back.clicked.connect(
lambda: self.workspace_stack.setCurrentWidget(self.account_page)
)
heading.addWidget(back)
layout.addLayout(heading)
filters = QHBoxLayout()
filters.addWidget(QLabel("原所属大号"))
self.exception_source_filter = QComboBox()
filters.addWidget(self.exception_source_filter)
filters.addWidget(QLabel("原执行小号"))
self.exception_worker_filter = QComboBox()
filters.addWidget(self.exception_worker_filter)
self.button(filters, "刷新异常任务", self.query_exception_tasks)
filters.addStretch()
layout.addLayout(filters)
layout.addWidget(
QLabel(
"unknown 只保留查询和保护占用,不提供人工核定、重发、重置、改派或状态修改。"
)
)
self.exception_table = self._task_table(checkable=False)
layout.addWidget(self.exception_table, 1)
self.exception_counts = QLabel("结果不确定 0 条")
layout.addWidget(self.exception_counts)
return page
def _task_names(self):
return self.data.get("task_names") or {
a["id"]: a["name"] for a in self.data.get("accounts", [])
}
def _refresh_detail_tasks(self, account):
if not account or account["role"] != "worker":
self._fill_task_table(self.current_task_table, [], self._task_names(), True)
self._fill_task_table(self.history_task_table, [], self._task_names())
self.detail_task_summary.setText("当前任务:—")
self.history_task_summary.setText("历史任务:—")
return
relevant = [
task
for task in self.data.get("tasks", [])
if task["worker"] == account["id"]
]
current = [
task
for task in relevant
if task["status"] in ("pending", "running", "unknown")
]
history = [
task
for task in relevant
if task["status"] not in ("pending", "running", "unknown")
]
names = self._task_names()
self._fill_task_table(self.current_task_table, current, names, True)
self._fill_task_table(self.history_task_table, history, names)
self.detail_task_summary.setText(
" · ".join(
[
f"当前任务 {len(current)}",
f"待执行 {sum(t['status'] == 'pending' for t in current)}",
f"执行中 {sum(t['status'] == 'running' for t in current)}",
f"结果不确定 {sum(t['status'] == 'unknown' for t in current)}",
]
)
)
self.history_task_summary.setText(f"历史任务 {len(history)} 条")
def refresh_exception_filters(self):
source = self.exception_source_filter.currentData()
worker = self.exception_worker_filter.currentData()
accounts = self.data.get("accounts", [])
self.exception_source_filter.blockSignals(True)
self.exception_worker_filter.blockSignals(True)
self.exception_source_filter.clear()
self.exception_source_filter.addItem("全部原组", "")
for account in accounts:
if account["role"] == "main":
self.exception_source_filter.addItem(account["name"], account["id"])
self.exception_worker_filter.clear()
self.exception_worker_filter.addItem("全部原执行小号", "")
for ident, name in sorted(
self._task_names().items(), key=lambda value: value[1]
):
if any(a["id"] == ident and a["role"] == "main" for a in accounts):
continue
self.exception_worker_filter.addItem(name, ident)
self.exception_source_filter.setCurrentIndex(
max(0, self.exception_source_filter.findData(source))
)
self.exception_worker_filter.setCurrentIndex(
max(0, self.exception_worker_filter.findData(worker))
)
self.exception_source_filter.blockSignals(False)
self.exception_worker_filter.blockSignals(False)
def show_exception_tasks(self):
self.workspace_stack.setCurrentWidget(self.exception_page)
self.refresh_exception_filters()
self.query_exception_tasks()
def query_exception_tasks(self):
self.send(
"task_query",
{
"source": self.exception_source_filter.currentData() or None,
"worker": self.exception_worker_filter.currentData() or None,
},
)
def render_exception_tasks(self, payload):
self.exception_tasks = payload.get("tasks", [])
self.exception_names = payload.get("task_names") or self._task_names()
self._fill_task_table(
self.exception_table, self.exception_tasks, self.exception_names
)
self.exception_counts.setText(f"结果不确定 {len(self.exception_tasks)} 条")
def history_full(self):
account = self.selected()
if not account or account["role"] != "main" or not account["uid"]:
QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。")
return
self.send("history_full", {"id": account["id"]})
def works_refresh(self):
account = self.selected()
if not account or account["role"] != "main" or not account["uid"]:
QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。")
return
self.send("works_refresh", {"id": account["id"]})
def works_full(self):
account = self.selected()
if not account or account["role"] != "main" or not account["uid"]:
QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。")
return
self.send("works_full", {"id": account["id"]})
def filter_history(self):
selected_kind = self.history_kind_filter.currentData()
for row in range(self.history_table.rowCount()):
item = self.history_table.item(row, 0)
self.history_table.setRowHidden(
row,
bool(selected_kind)
and (
item is None
or item.data(Qt.ItemDataRole.UserRole + 1) != selected_kind
),
)
def confirm_history(self):
payload = self.history_payload
if not payload:
QMessageBox.information(self, "提示", "请先同步历史事件。")
return
selected = self.checked_table_data(self.history_table)
if not selected:
QMessageBox.information(self, "没有选择", "当前显示范围内未选择任何事件。")
return
QMessageBox.information(
self,
"历史事件操作预览",
f"将提交 {len(selected)} 条历史事件,按当前规则交给组内小号处理。\n\n这可能产生真实关注和私信;冷却、去重和作品范围仍会再次校验。",
)
if (
QMessageBox.question(
self,
"确认历史批量操作",
f"确认提交 {len(selected)} 条历史事件?结果不确定不会重发。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
== QMessageBox.StandardButton.Yes
):
self.send(
"history_enqueue",
{
"id": payload["account"],
"ids": selected,
"baseline": payload.get("baseline"),
},
)
def save_work_filter(self):
account = self.selected()
if not account or account["role"] != "main":
QMessageBox.information(self, "提示", "请先选择大号。")
return
mode = "all" if self.works_all_mode.isChecked() else "selected"
selected = self.checked_table_data(self.works_table)
if mode == "selected" and not selected:
QMessageBox.warning(
self,
"没有选择作品",
"指定作品模式至少选择一个作品;如需暂停请关闭自动操作规则。",
)
return
self.send(
"work_filter",
{
"id": account["id"],
"mode": mode,
"ids": selected,
"refresh_interval": self.works_refresh_minutes.value() * 60,
},
)
def cancel_selected_pending(self):
account = self.selected()
if not account or account["role"] != "worker":
QMessageBox.information(self, "提示", "请先选择小号。")
return
selected = self.checked_table_data(self.current_task_table)
if not selected:
QMessageBox.information(self, "没有选择", "请选择仍为 pending 的任务。")
return
if (
QMessageBox.question(
self,
"取消 pending 任务",
f"确认取消选中的 {len(selected)} 条 pending 任务?仅确认时仍为 pending 的任务会被取消。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
== QMessageBox.StandardButton.Yes
):
self.send(
"cancel_pending",
{"id": account["id"], "task_ids": selected},
)
def show_start_preview(self, payload):
groups = payload.get("groups", [])
pending = payload.get("tasks", [])
names = self._task_names()
group_names = ", ".join(names.get(group, group) for group in groups)
oldest = min(
(task.get("created_at") for task in pending if task.get("created_at")),
default=None,
)
text = f"即将启动账号组:{group_names or '未知'}\n旧 pending:{len(pending)} 条"
if oldest:
text += f"\n最早创建:{date_text(oldest)}"
rule_text = []
for rule in payload.get("rules") or []:
actions = []
if rule.get("follow"):
actions.append("关注")
if rule.get("dm"):
actions.append("私信")
rule_text.append(
f"{names.get(rule.get('id'), rule.get('id', '未知'))}:"
f"{'、'.join(actions) or '不执行'},间隔 {rule.get('interval', '未知')} 秒"
)
if rule_text:
text += "\n规则快照:" + ";".join(rule_text)
text += "\n\n恢复选择不会启动业务;账号安全限制会在领取前再次校验。"
box = QMessageBox(self)
box.setWindowTitle("启动前确认")
box.setText(text)
continue_button = box.addButton("继续启动", QMessageBox.ButtonRole.AcceptRole)
cancel_button = (
box.addButton("取消这些旧 pending", QMessageBox.ButtonRole.DestructiveRole)
if pending
else None
)
back_button = box.addButton("返回", QMessageBox.ButtonRole.RejectRole)
box.setDefaultButton(back_button)
box.exec()
clicked = box.clickedButton()
if clicked is continue_button:
self.send("start", {"groups": groups})
elif cancel_button is not None and clicked is cancel_button:
self.send("cancel_pending", {"tasks": pending})
def start_group_request(self, groups):
self.send("start_preview", {"groups": groups})
def send(self, name, data=None):
if not self.backend.isRunning():
QMessageBox.warning(
self, "核心未运行", "请退出后重新启动应用,检查运行环境。"
)
return
data = dict(data or {})
request_id = uuid.uuid4().hex
data["_request_id"] = request_id
if data.get("id"):
self.latest_requests[(name, data["id"])] = request_id
if name in ("task_query", "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(
"正在处理;停止会先关闭调度和领取入口,请勿强制退出……"
)
self.backend.post(name, data)
def response(self, name, success, message):
for button in self.buttons:
button.setEnabled(not self.quitting and name != "startup")
self.stop_button.setEnabled(not self.quitting)
self.statusBar().showMessage(message)
if not success:
QMessageBox.warning(self, "需要检查", message)
def append_logs(self, lines):
if not lines:
return
scroll = self.log.verticalScrollBar()
previous = scroll.value()
self.log.appendPlainText("\n".join(lines[-UI_LIMIT:]))
scroll.setValue(scroll.maximum() if self.auto_scroll.isChecked() else previous)
def open_logs(self):
directory = self.data.get("log_directory") or str(
Path(self.backend.root) / "logs"
)
if not QDesktopServices.openUrl(QUrl.fromLocalFile(directory)):
QMessageBox.warning(self, "无法打开日志目录", directory)
def delete_worker(self):
account = self.selected()
if not account or account["role"] != "worker":
QMessageBox.information(
self, "提示", "请先选择要删除的小号,大号不支持此操作。"
)
return
if (
QMessageBox.question(
self,
"删除小号 — 不可恢复登录数据",
f"确定删除小号“{account['name']}”?\n\n将关闭该小号浏览器、删除全部本地登录数据、取消待执行任务,并从列表移除。\n历史任务和日志保留;UID 仍受原记录保护,只能显式恢复原账号。\n\n请先停止所属组并等待执行结束。删除文件失败时账号会保留,可显式重试。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
== QMessageBox.StandardButton.Yes
):
self.send("delete_worker", {"id": account["id"]})
def history_events(self):
account = self.selected()
if not account or account["role"] != "main" or not account["uid"]:
QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。")
return
self.send("history_fetch", {"id": account["id"]})
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_open", {"id": account["id"]})
def command_data(self, name, payload):
if not isinstance(payload, dict):
return
account_id = payload.get("account")
request_id = payload.get("_request_id")
if (
account_id
and request_id
and self.latest_requests.get((name, account_id)) != request_id
):
return
if (
name in ("task_query", "start_preview")
and request_id
and self.latest_global_requests.get(name) != request_id
):
return
if name in ("history_fetch", "history_full"):
self.history_dialog(payload)
elif name in ("works_open", "works_refresh", "works_full"):
self.works_dialog(payload)
elif name == "task_query":
self.render_exception_tasks(payload)
elif name == "start_preview":
self.show_start_preview(payload)
elif name == "cancel_pending":
QMessageBox.information(
self,
"pending 任务处理完成",
f"已取消 {payload.get('cancelled', 0)} 条仍为 pending 的任务;在途或已变化的任务未被改写。",
)
elif name == "history_enqueue":
QMessageBox.information(
self,
"历史事件已确认",
f"选择 {payload['selected']} 条;新增事件 {payload['events']} 条;创建任务 {payload['tasks']} 条。\n重复、规则不匹配、作品范围外、冷却或自操作事件不会创建任务。新消息优先。",
)
elif name == "work_filter":
text = (
"全部作品"
if payload["mode"] == "all"
else f"指定的 {payload['count']} 个作品"
)
QMessageBox.information(self, "作品监控已保存", "当前监控:" + text)
@staticmethod
def set_table_checks(table, state):
for row in range(table.rowCount()):
item = table.item(row, 0)
if item is not None and not table.isRowHidden(row):
item.setCheckState(state)
@staticmethod
def checked_table_data(table):
values = []
for row in range(table.rowCount()):
item = table.item(row, 0)
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.get("items") or []
labels = NOTICE_KIND_LABELS
self.history_payload = payload
sync = self._sync_text(payload.get("sync"), "历史事件同步")
self.history_info.setText(
f"{sync} · 当前可选 {len(items)} 条;确认前不创建任务、不标记已读。规则和作品范围基线会在提交前再次校验。"
)
self.history_kind_filter.blockSignals(True)
current_kind = self.history_kind_filter.currentData()
self.history_kind_filter.clear()
self.history_kind_filter.addItem("全部类型", "")
for kind in dict.fromkeys(item.get("kind") for item in items):
if kind:
self.history_kind_filter.addItem(labels.get(kind, "其他互动"), kind)
self.history_kind_filter.setCurrentIndex(
max(0, self.history_kind_filter.findData(current_kind))
)
self.history_kind_filter.blockSignals(False)
self.history_table.setRowCount(len(items))
for row, item in enumerate(items):
check = QTableWidgetItem()
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"])
self.history_table.setItem(row, 0, check)
users = []
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})")
values = [
date_text(item.get("create_time")),
labels.get(item.get("kind"), "其他互动"),
"、".join(users) or "未知用户",
item.get("work_desc") or "未提供作品名称",
item.get("work_id") or "无作品ID",
item.get("comment") or "无文字内容",
item["nid"],
"实时收到" if item.get("origin") == "live" else "历史同步",
]
for column, value in enumerate(values, 1):
cell = QTableWidgetItem(str(value))
cell.setToolTip(str(value))
self.history_table.setItem(row, column, cell)
self.filter_history()
def works_dialog(self, payload):
items = payload.get("items") or []
existing = set(payload.get("selected") or [])
self.works_payload = payload
self.works_all_mode.setChecked(payload.get("mode") == "all")
try:
refresh_interval = int(payload.get("refresh_interval", 3600))
except (TypeError, ValueError):
refresh_interval = 3600
self.works_refresh_minutes.setValue(refresh_interval // 60)
self.works_info.setText(
f"{self._sync_text(payload.get('sync'), '作品同步')} · 本地 {len(items)} 个作品 · 本次更新 {payload.get('refreshed', 0)} 个;完整标记和覆盖数量以数据库为准。"
)
if self._works_table_sync is not None:
with suppress(RuntimeError, TypeError):
self.works_table.itemChanged.disconnect(self._works_table_sync)
self.works_table.setRowCount(len(items))
self._clear_layout(self.works_card_grid)
cards = []
cards_by_id = {}
for row, item in enumerate(items):
check = QTableWidgetItem()
check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
check.setCheckState(
Qt.CheckState.Checked
if item["aweme_id"] in existing
else Qt.CheckState.Unchecked
)
check.setData(Qt.ItemDataRole.UserRole, item["aweme_id"])
self.works_table.setItem(row, 0, check)
statistics = item.get("statistics") or {}
values = [
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.get("cover") or "无封面地址",
date_text(item.get("updated")),
]
for column, value in enumerate(values, 1):
cell = QTableWidgetItem(str(value))
cell.setToolTip(str(value))
self.works_table.setItem(row, column, cell)
card = WorkCard(item, check.checkState() == Qt.CheckState.Checked)
cards.append(card)
cards_by_id.setdefault(item.get("aweme_id", ""), []).append(card)
self.works_card_grid.addWidget(card, row // 3, row % 3)
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 = self.works_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)
self._works_table_sync = sync_card
self.works_table.itemChanged.connect(self._works_table_sync)
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:
self.statusBar().showMessage("读取作品封面缓存失败,继续使用在线图片")
if not pixmap.isNull():
for card in cards_by_id.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=reply: (
reply.abort() if received > 2_000_000 or total > 2_000_000 else None
)
)
def loaded():
raw = bytes(reply.readAll().data())
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:
self.statusBar().showMessage(
"作品封面缓存写入失败,本次仅显示图片"
)
for card in cards_by_id.get(ident, []):
card.set_cover(image)
reply.deleteLater()
reply.finished.connect(loaded)
for item in items:
load_cover(item)
self._toggle_works_selection(self.works_all_mode.isChecked())
def restore_selection(self):
self.selection_loaded = False
self.signature = None
self.refresh(self.data)
self.statusBar().showMessage("已恢复上次选择;不会启动业务")
def account_menu(self, account_id, global_pos):
if account_id:
self.card_table.set_current_id(account_id)
menu = QMenu(self)
account = self.selected()
if account:
menu.addAction("登录 / 刷新账号信息", lambda: self.selected_command("open"))
menu.addAction("打开账号浏览器", lambda: self.selected_command("open"))
if account["role"] == "main":
menu.addAction(
"启动本组", lambda: self.start_group_request([account["id"]])
)
menu.addAction("停止本组", self.stop_selected_group)
menu.addAction("配置规则", self.rules)
else:
menu.addAction("切换 / 解除归属", self.move)
menu.addAction("删除小号", self.delete_worker)
menu.addAction("账号浏览器设置", lambda: self.browser_settings(True))
menu.addAction("关闭浏览器", lambda: self.selected_command("close_browser"))
menu.addAction("删除登录数据", self.erase)
else:
menu.addAction("刷新列表", lambda: self.refresh(self.data))
menu.addAction("打开日志目录", self.open_logs)
menu.addAction("全局浏览器设置", lambda: self.browser_settings(False))
menu.exec(global_pos)
def selected(self):
ident = self.card_table.selected_id()
return next((a for a in self.data["accounts"] if a["id"] == ident), None)
def show_profile(self):
account = self.selected()
if not account:
self.detail_account_id = None
self.detail_title.setText("选择账号")
self.profile_summary.setText("选择账号可查看自动获取的资料与统计")
self.detail_task_summary.setText("当前任务:—")
self.history_task_summary.setText("历史任务:—")
self.history_payload = None
self.works_payload = None
self.history_table.setRowCount(0)
self.works_table.setRowCount(0)
self._clear_layout(self.works_card_grid)
return
account_changed = self.detail_account_id != account["id"]
if account_changed:
self.history_payload = None
self.works_payload = None
self.history_table.setRowCount(0)
self.works_table.setRowCount(0)
self._clear_layout(self.works_card_grid)
self.history_kind_filter.setCurrentIndex(0)
self.works_all_mode.setChecked(False)
self.history_info.setText(
"历史事件尚未同步;同步只保存本地数据,不标记已读,也不会自动创建任务。"
)
self.works_info.setText(
"作品缓存尚未同步;完整标记与覆盖数量以本地数据库为准。"
)
self.detail_account_id = account["id"]
profile = decode(account.get("profile", "{}"))
counts = " · ".join(
f"{label} {profile.get(key) if profile.get(key) is not None else '—'}"
for key, label in [
("follower_count", "粉丝"),
("following_count", "关注"),
("total_favorited", "获赞"),
("aweme_count", "作品"),
]
)
is_main = account["role"] == "main"
for index in range(self.detail_tabs.count()):
self.detail_tabs.setTabVisible(index, index >= 2 if is_main else index < 2)
if account_changed:
self.detail_tabs.setCurrentIndex(2 if is_main else 0)
relevant = [
task
for task in self.data.get("tasks", [])
if task["worker"] == account["id"]
or (is_main and task["source"] == account["id"])
]
current = sum(
task["status"] in ("pending", "running", "unknown") for task in relevant
)
deletion = (
f" · 删除收尾:{account['delete_step']}"
if account.get("delete_state")
else ""
)
sync = account.get("sync") or {}
self.detail_title.setText(
f"{account['name']} · {'大号' if is_main 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('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)
def selected_command(self, name):
account = self.selected()
if account:
self.send(name, {"id": account["id"]})
else:
QMessageBox.information(self, "提示", "请先选择一个账号。")
def checked_groups(self):
return self.card_table.checked_groups()
def start_groups(self):
groups = self.checked_groups()
if not groups:
QMessageBox.information(
self, "提示", "请勾选至少一个大号。上次选择会在重启后自动勾选。"
)
return
self.start_group_request(groups)
def stop_selected_group(self):
account = self.selected()
if account:
ident = account["id"] if account["role"] == "main" else account["owner"]
if ident:
self.send("stop_group", {"id": ident})
def restore_worker(self):
deleted = self.data.get("deleted_accounts", [])
if not deleted:
QMessageBox.information(self, "提示", "没有可恢复的已删除小号。")
return
win, form, _buttons = dialog("恢复已删除小号", self)
account_box = QComboBox()
for account in deleted:
label = f"{account['name']} · UID {account['uid'] or '未知'}"
if account.get("delete_error"):
label += f" · {account['delete_error']}"
account_box.addItem(label, account["id"])
owner_box = QComboBox()
owner_box.addItem("暂不归属", None)
for account in self.data["accounts"]:
if account["role"] == "main":
owner_box.addItem(account["name"], account["id"])
form.addRow("原账号", account_box)
form.addRow("恢复归属", owner_box)
if win.exec() == QDialog.DialogCode.Accepted:
self.send(
"restore_worker",
{"id": account_box.currentData(), "owner": owner_box.currentData()},
)
def add(self, role):
win, form, buttons = dialog("添加大号" if role == "main" else "添加小号", self)
name = QLineEdit()
name.setPlaceholderText("例如:主账号、客服一号")
form.addRow("自定义名称", name)
owner = QComboBox()
owner.addItem("未归属", None)
for a in self.data["accounts"]:
if a["role"] == "main":
owner.addItem(a["name"], a["id"])
if role == "worker":
form.addRow("归属大号", owner)
restore = QPushButton("恢复已删除小号")
def open_restore():
win.reject()
self.restore_worker()
restore.clicked.connect(open_restore)
form.addRow(restore)
form.addRow(
QLabel(
"保存后自动打开独立浏览器,请手动登录。UID、昵称、头像和统计信息会自动回填,无需查找或输入账号 ID。"
)
)
form.addRow(buttons)
if win.exec() == QDialog.DialogCode.Accepted:
self.send(
"add",
{
"role": role,
"name": name.text(),
"owner": owner.currentData() if role == "worker" else None,
},
)
def rules(self):
account = self.selected()
if not account or account["role"] != "main":
QMessageBox.information(self, "提示", "请先选择大号。")
return
rule = decode(account["rule"])
win, form, buttons = dialog("自动操作规则 — " + account["name"], self)
enabled, follow, dm = [QCheckBox() for _ in range(3)]
for widget, key, label in [
(enabled, "enabled", "启用自动操作"),
(follow, "follow", "执行关注"),
(dm, "dm", "发送私信"),
]:
widget.setChecked(rule[key])
form.addRow(label, widget)
kinds = {}
for kind, label in [
("digg", "点赞"),
("follow", "关注"),
("comment", "评论"),
("favorite", "收藏"),
("share", "分享"),
("general_notice", "其他作品互动(仅有明确来源 UID 时)"),
]:
check = QCheckBox(label)
check.setChecked(kind in rule["kinds"])
form.addRow("触发通知", check)
kinds[kind] = check
text = QPlainTextEdit(rule["text"])
text.setPlaceholderText("发送给通知来源用户的固定正文,最多 1000 字")
form.addRow("私信正文", text)
interval = QSpinBox()
interval.setRange(5, 86400)
interval.setValue(rule["interval"])
form.addRow("不同任务批次间隔(秒)", interval)
cooldown = QSpinBox()
cooldown.setRange(0, 525600)
cooldown.setSpecialValueText("关闭")
cooldown.setSuffix(" 分钟")
cooldown.setValue(rule.get("cooldown", 14400) // 60)
form.addRow("同组同 UID 冷却", cooldown)
warning = QLabel(
"每条通知只分配给一个小号,组内轮询。同一 UID 的冷却由整个大号组共享,默认 240 分钟;冷却从任务实际开始时计算。\n关注和私信同时开启时会并行执行、结果互不依赖,不等待批次间隔。已排队任务保留原规则快照;结果不确定不会重发。"
)
warning.setWordWrap(True)
form.addRow(warning)
form.addRow(buttons)
if win.exec() == QDialog.DialogCode.Accepted:
value = {
"enabled": enabled.isChecked(),
"follow": follow.isChecked(),
"dm": dm.isChecked(),
"require_follow": False,
"kinds": [k for k, v in kinds.items() if v.isChecked()],
"text": text.toPlainText(),
"interval": interval.value(),
"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)
except ValueError as exc:
QMessageBox.warning(self, "规则无效", str(exc))
return
self.send("rule", {"id": account["id"], "rule": value})
def move(self):
account = self.selected()
if not account or account["role"] != "worker":
QMessageBox.information(self, "提示", "请先选择小号。")
return
win, form, buttons = dialog("切换 / 解除归属", self)
owner = QComboBox()
owner.addItem("未归属", None)
for a in self.data["accounts"]:
if a["role"] == "main":
owner.addItem(a["name"], a["id"])
owner.setCurrentIndex(max(0, owner.findData(account["owner"])))
pending = sum(
t["worker"] == account["id"] and t["status"] == "pending"
for t in self.data["tasks"]
)
form.addRow("新归属", owner)
text = QLabel(
f"确认后取消旧组全部待执行任务(最近 500 条中有 {pending} 条),不改派、不重发。\n执行中禁止变更;历史与结果不确定任务保留;登录目录不变。"
)
text.setWordWrap(True)
form.addRow(text)
form.addRow(buttons)
if win.exec() == QDialog.DialogCode.Accepted:
self.send("move", {"id": account["id"], "owner": owner.currentData()})
def browser_settings(self, per_account):
account = self.selected() if per_account else None
if per_account and not account:
QMessageBox.information(self, "提示", "请先选择一个账号。")
return
config = (
self.data["browser_overrides"].get(account["id"])
if account
else self.data["browser"]
)
config = config or {}
win, form, buttons = dialog(
"账号浏览器设置" if account else "默认浏览器设置", self
)
path = QLineEdit(config.get("executable_path", ""))
browse = QPushButton("选择 fingerprint-chromium 可执行文件")
def choose():
selected, _ = QFileDialog.getOpenFileName(
win, "选择 fingerprint-chromium", "", "浏览器 (*.exe);;全部文件 (*)"
)
if selected:
path.setText(selected)
browse.clicked.connect(choose)
form.addRow("可执行文件(可留空)", path)
form.addRow(browse)
args = QPlainTextEdit("\n".join(config.get("extra_args", [])))
args.setPlaceholderText("--lang=zh-CN\n--window-size=1280,900")
form.addRow("高级参数,每行一个", args)
label = QLabel(
"留空使用包内 fingerprint-chromium,不再回退到系统 Chrome。升级后请先关闭旧浏览器,再手动登录新内核;旧登录目录保留但不复制 Cookie。每个账号固定指纹种子,不随重启或归属变化。更换内核/驱动不保证平台无法检测。"
)
label.setWordWrap(True)
form.addRow(label)
form.addRow(buttons)
if win.exec() == QDialog.DialogCode.Accepted:
value: dict = {
"extra_args": [
a.strip() for a in args.toPlainText().splitlines() if a.strip()
]
}
if path.text().strip():
value["executable_path"] = path.text().strip()
self.send(
"browser_config",
{"id": account["id"] if account else None, "config": value},
)
def erase(self):
account = self.selected()
if (
account
and QMessageBox.warning(
self,
"永久删除登录数据",
f"删除“{account['name']}”所有浏览器版本的本地登录目录?\n需要重新手动登录。任务、账号定义与历史仍保留。此操作不可撤销。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
== QMessageBox.StandardButton.Yes
):
self.send("erase", {"id": account["id"]})
def avatar(self, account):
key = account["avatar"]
if (
key
and key not in self.icons
and key not in self.avatar_pending
and allowed_image_url(key)
):
url = QUrl(key)
self.avatar_pending.add(key)
request = QNetworkRequest(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():
self.avatar_pending.discard(key)
pixmap = QPixmap()
if reply.error() == reply.NetworkError.NoError:
pixmap.loadFromData(bytes(reply.readAll().data()))
self.icons[key] = (
QIcon(pixmap.scaled(32, 32)) if not pixmap.isNull() else QIcon()
)
reply.deleteLater()
self.signature = None
reply.finished.connect(loaded)
return self.icons.get(key, QIcon())
def refresh(self, data):
signature = json.dumps(data, sort_keys=True)
if signature == self.signature:
return
old_account = self.selected()
selected_id = old_account["id"] if old_account else None
known = {a["id"] for a in self.data["accounts"]}
new_accounts = [a for a in data["accounts"] if a["id"] not in known]
if new_accounts:
selected_id = new_accounts[-1]["id"]
checked = self.checked_groups() if self.selection_loaded else data["selected"]
self.data = data
self.log_info.setText(
data.get("log_file_error")
or (
"按天保存完整日志:"
+ data.get("log_directory", str(Path(self.backend.root) / "logs"))
+ ";界面仅显示最近 2000 条"
)
)
self.signature = signature
self.selection_loaded = True
display = {}
for account in data["accounts"]:
identity = account.get("identity", "")
state = account.get("state") or "未启动"
if "未通过" in identity or "失败" in state:
color = "#d4380d"
elif account["id"] in data.get("active", []):
color = "#389e0d"
elif "待登录" in account.get("login", ""):
color = "#d48806"
else:
color = "#68707d"
display[account["id"]] = {
"title": account["name"] or "未命名账号",
"meta": f"名称:{account['nickname'] or '待登录自动获取'}",
"status": f"{'监听' if account['role'] == 'main' else '执行'}:{state}",
"color": color,
}
self.card_table.set_accounts(data["accounts"], display, checked, selected_id)
self.show_profile()
self.refresh_exception_filters()
def closeEvent(self, event):
if self.allow_close:
event.accept()
return
event.ignore()
if not self.quitting:
self.quitting = True
for button in self.buttons:
button.setEnabled(False)
self.statusBar().showMessage("正在安全停止并保存结果,请勿强制结束……")
if self.backend.isRunning():
self.backend.post("quit")
else:
self.finished()
def finished(self):
if self.quitting:
self.allow_close = True
self.close()
else:
self.statusBar().showMessage("核心已停止,请重启应用")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-dir", type=Path, default=default_root())
parser.add_argument(
"--smoke-test",
type=Path,
help="离线 Qt/SQLite/Patchright 打包冒烟,写 JSON 后退出,不启动 Chrome 或业务",
)
args = parser.parse_args()
app = QApplication(sys.argv[:1])
root = (
Path(tempfile.mkdtemp(prefix="douyin-smoke-"))
if args.smoke_test
else args.data_dir.resolve()
)
lock = None
try:
lock = AppLock(root)
protect_root(root)
backend = Backend(root)
window = Window(backend)
window.show()
if args.smoke_test:
def finish_smoke(data):
if not backend.isRunning() or window.quitting:
return
try:
browser = str(browser_executable({}))
except ValueError:
browser = "not-installed"
report = {
"qt": True,
"core": True,
"sqlite": True,
"patchright": True,
"accounts": len(data["accounts"]),
"browser": browser,
"real_write_actions": 0,
}
args.smoke_test.parent.mkdir(parents=True, exist_ok=True)
args.smoke_test.write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
window.close()
backend.snapshot_ready.connect(finish_smoke)
QTimer.singleShot(30000, window.close)
backend.start()
result = app.exec()
backend.wait(120000)
return result
except (OSError, RuntimeError) as exc:
QMessageBox.critical(None, "启动失败", str(exc))
return 1
finally:
if lock:
lock.close()
if __name__ == "__main__":
raise SystemExit(main())