2170 lines
86 KiB
Python
2170 lines
86 KiB
Python
"""中文 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())
|