feat(collect): add product status recheck
This commit is contained in:
@@ -1207,6 +1207,27 @@ def collect(account, task, on_step=None) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def recheck_product_status(account, task, on_step=None) -> dict:
|
||||||
|
"""Read one product status snapshot without changing collected content."""
|
||||||
|
|
||||||
|
item_id = _item_id(task)
|
||||||
|
cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False)
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
_notify_collect_step(on_step, "read_product_status")
|
||||||
|
result = read_product_status(cdp)
|
||||||
|
if result.get("product_status_error"):
|
||||||
|
raise EditorError(
|
||||||
|
"商品状态读取失败:{error}".format(
|
||||||
|
error=result["product_status_error"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
close_target_confirmed = _close_collected_product(cdp)
|
||||||
|
result["close_target_confirmed"] = close_target_confirmed
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def read_product_status(cdp) -> dict:
|
def read_product_status(cdp) -> dict:
|
||||||
"""Read the current product's warning state without retaining page HTML."""
|
"""Read the current product's warning state without retaining page HTML."""
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ if QT_IMPORT_ERROR is None:
|
|||||||
CMHubSettingsWorker,
|
CMHubSettingsWorker,
|
||||||
ApplyWorker,
|
ApplyWorker,
|
||||||
CollectWorker,
|
CollectWorker,
|
||||||
|
StatusRecheckWorker,
|
||||||
GenerateWorker,
|
GenerateWorker,
|
||||||
ImageStudioDownloadOriginalWorker,
|
ImageStudioDownloadOriginalWorker,
|
||||||
ImageStudioExportWorker,
|
ImageStudioExportWorker,
|
||||||
|
|||||||
@@ -651,7 +651,10 @@ class ApplyTab(QWidget):
|
|||||||
if status_text:
|
if status_text:
|
||||||
lines.append("当前筛选结果没有状态正常且可更新的商品。")
|
lines.append("当前筛选结果没有状态正常且可更新的商品。")
|
||||||
lines.append(status_text)
|
lines.append(status_text)
|
||||||
lines.append("请先回到①导入采集重新确认商品状态。")
|
if update_plan.get("unverified"):
|
||||||
|
lines.append("请先到①导入采集点击「重新检测商品状态」。")
|
||||||
|
else:
|
||||||
|
lines.append("请先回到①导入采集重新确认商品状态。")
|
||||||
content_error = self._update_content_error(update_plan, update_mode)
|
content_error = self._update_content_error(update_plan, update_mode)
|
||||||
if content_error:
|
if content_error:
|
||||||
if lines:
|
if lines:
|
||||||
@@ -727,6 +730,7 @@ class ApplyTab(QWidget):
|
|||||||
if not update_plan:
|
if not update_plan:
|
||||||
return ""
|
return ""
|
||||||
rows = [
|
rows = [
|
||||||
|
("尚未检测商品状态", update_plan.get("unverified", [])),
|
||||||
("未上架", update_plan.get("unlisted", [])),
|
("未上架", update_plan.get("unlisted", [])),
|
||||||
("审核中", update_plan.get("reviewing", [])),
|
("审核中", update_plan.get("reviewing", [])),
|
||||||
("状态未知", update_plan.get("unknown", [])),
|
("状态未知", update_plan.get("unknown", [])),
|
||||||
|
|||||||
+230
-21
@@ -11,13 +11,26 @@ from ...collect_skip import (
|
|||||||
from ... import product_status
|
from ... import product_status
|
||||||
from ..models import TaskTableModel
|
from ..models import TaskTableModel
|
||||||
from ..widgets import *
|
from ..widgets import *
|
||||||
from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker
|
from ..workers import (
|
||||||
|
CollectWorker as _RealCollectWorker,
|
||||||
|
StatusRecheckWorker as _RealStatusRecheckWorker,
|
||||||
|
WriteBackWorker as _RealWriteBackWorker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def CollectWorker(*args, **kwargs):
|
def CollectWorker(*args, **kwargs):
|
||||||
return _call_package_attr("CollectWorker", _RealCollectWorker, *args, **kwargs)
|
return _call_package_attr("CollectWorker", _RealCollectWorker, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def StatusRecheckWorker(*args, **kwargs):
|
||||||
|
return _call_package_attr(
|
||||||
|
"StatusRecheckWorker",
|
||||||
|
_RealStatusRecheckWorker,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def WriteBackWorker(*args, **kwargs):
|
def WriteBackWorker(*args, **kwargs):
|
||||||
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
|
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
|
||||||
|
|
||||||
@@ -84,15 +97,19 @@ class CollectTab(QWidget):
|
|||||||
self.last_import_stats = None
|
self.last_import_stats = None
|
||||||
self.collect_worker = None
|
self.collect_worker = None
|
||||||
self.collect_thread = None
|
self.collect_thread = None
|
||||||
|
self.status_recheck_worker = None
|
||||||
|
self.status_recheck_thread = None
|
||||||
self.write_back_worker = None
|
self.write_back_worker = None
|
||||||
self.write_back_thread = None
|
self.write_back_thread = None
|
||||||
self.last_collect_run_id = None
|
self.last_collect_run_id = None
|
||||||
|
self.last_status_recheck_run_id = None
|
||||||
self._collect_run_started_at = None
|
self._collect_run_started_at = None
|
||||||
self._collect_task_started_at = None
|
self._collect_task_started_at = None
|
||||||
self._collect_task_elapsed_seconds = 0
|
self._collect_task_elapsed_seconds = 0
|
||||||
self._collect_activity_payload = {}
|
self._collect_activity_payload = {}
|
||||||
self._collect_terminal_text = ""
|
self._collect_terminal_text = ""
|
||||||
self._collect_stop_requested = False
|
self._collect_stop_requested = False
|
||||||
|
self._collect_operation_label = "采集"
|
||||||
self._collect_elapsed_timer = QTimer(self)
|
self._collect_elapsed_timer = QTimer(self)
|
||||||
self._collect_elapsed_timer.setInterval(1000)
|
self._collect_elapsed_timer.setInterval(1000)
|
||||||
self._collect_elapsed_timer.timeout.connect(self._refresh_collect_activity)
|
self._collect_elapsed_timer.timeout.connect(self._refresh_collect_activity)
|
||||||
@@ -100,6 +117,8 @@ class CollectTab(QWidget):
|
|||||||
self.import_button = QPushButton("导入 Excel...")
|
self.import_button = QPushButton("导入 Excel...")
|
||||||
self.refresh_button = QPushButton("刷新")
|
self.refresh_button = QPushButton("刷新")
|
||||||
self.collect_button = QPushButton("采集旧标题/旧封面")
|
self.collect_button = QPushButton("采集旧标题/旧封面")
|
||||||
|
self.status_recheck_button = QPushButton("重新检测商品状态")
|
||||||
|
self.status_recheck_button.setObjectName("statusRecheckButton")
|
||||||
self.stop_collect_button = QPushButton("停止")
|
self.stop_collect_button = QPushButton("停止")
|
||||||
self.write_back_button = QPushButton("回写旧数据到 Excel")
|
self.write_back_button = QPushButton("回写旧数据到 Excel")
|
||||||
self.stop_collect_button.setEnabled(False)
|
self.stop_collect_button.setEnabled(False)
|
||||||
@@ -123,6 +142,7 @@ class CollectTab(QWidget):
|
|||||||
toolbar.addWidget(self.import_button)
|
toolbar.addWidget(self.import_button)
|
||||||
toolbar.addWidget(self.refresh_button)
|
toolbar.addWidget(self.refresh_button)
|
||||||
toolbar.addWidget(self.collect_button)
|
toolbar.addWidget(self.collect_button)
|
||||||
|
toolbar.addWidget(self.status_recheck_button)
|
||||||
toolbar.addWidget(self.stop_collect_button)
|
toolbar.addWidget(self.stop_collect_button)
|
||||||
toolbar.addWidget(self.write_back_button)
|
toolbar.addWidget(self.write_back_button)
|
||||||
toolbar.addStretch(1)
|
toolbar.addStretch(1)
|
||||||
@@ -144,7 +164,7 @@ class CollectTab(QWidget):
|
|||||||
self.collect_activity_label = QLabel("")
|
self.collect_activity_label = QLabel("")
|
||||||
self.collect_activity_label.setObjectName("collectActivityLabel")
|
self.collect_activity_label.setObjectName("collectActivityLabel")
|
||||||
self.collect_activity_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
self.collect_activity_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
activity_sample = "正在采集 999/999 · 等待商品页加载 · 本条 99:59"
|
activity_sample = "正在商品状态重检 999/999 · 等待商品页加载 · 本条 99:59"
|
||||||
activity_width = self.collect_activity_label.fontMetrics().horizontalAdvance(activity_sample) + 24
|
activity_width = self.collect_activity_label.fontMetrics().horizontalAdvance(activity_sample) + 24
|
||||||
self.collect_activity_label.setFixedWidth(activity_width)
|
self.collect_activity_label.setFixedWidth(activity_width)
|
||||||
self.collect_activity_label.setVisible(False)
|
self.collect_activity_label.setVisible(False)
|
||||||
@@ -211,6 +231,7 @@ class CollectTab(QWidget):
|
|||||||
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||||
self.delete_batch_button.clicked.connect(self.delete_current_batch)
|
self.delete_batch_button.clicked.connect(self.delete_current_batch)
|
||||||
self.collect_button.clicked.connect(self.collect_old_data)
|
self.collect_button.clicked.connect(self.collect_old_data)
|
||||||
|
self.status_recheck_button.clicked.connect(self.recheck_product_status)
|
||||||
self.stop_collect_button.clicked.connect(self.stop_collect)
|
self.stop_collect_button.clicked.connect(self.stop_collect)
|
||||||
self.write_back_button.clicked.connect(self.write_back_old_data)
|
self.write_back_button.clicked.connect(self.write_back_old_data)
|
||||||
self.show_all_button.clicked.connect(self.show_all_tasks)
|
self.show_all_button.clicked.connect(self.show_all_tasks)
|
||||||
@@ -232,8 +253,9 @@ class CollectTab(QWidget):
|
|||||||
"}"
|
"}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start_collect_activity(self):
|
def _start_collect_activity(self, operation_label="采集"):
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
self._collect_operation_label = str(operation_label or "采集")
|
||||||
self._collect_run_started_at = now
|
self._collect_run_started_at = now
|
||||||
self._collect_task_started_at = None
|
self._collect_task_started_at = None
|
||||||
self._collect_task_elapsed_seconds = 0
|
self._collect_task_elapsed_seconds = 0
|
||||||
@@ -306,7 +328,7 @@ class CollectTab(QWidget):
|
|||||||
level = "warning"
|
level = "warning"
|
||||||
elif state in {"task_started", "task_step"}:
|
elif state in {"task_started", "task_step"}:
|
||||||
text = (
|
text = (
|
||||||
f"正在采集 {progress} · {step_label} · "
|
f"正在{self._collect_operation_label} {progress} · {step_label} · "
|
||||||
f"本条 {_format_collect_elapsed(task_elapsed)}"
|
f"本条 {_format_collect_elapsed(task_elapsed)}"
|
||||||
)
|
)
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -322,7 +344,10 @@ class CollectTab(QWidget):
|
|||||||
)
|
)
|
||||||
level = "danger" if event.get("result") == "failed" else "muted"
|
level = "danger" if event.get("result") == "failed" else "muted"
|
||||||
else:
|
else:
|
||||||
text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}"
|
if self._collect_operation_label == "采集":
|
||||||
|
text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}"
|
||||||
|
else:
|
||||||
|
text = f"正在{self._collect_operation_label}前检查账号 · {_format_collect_elapsed(run_elapsed)}"
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|
||||||
tooltip_parts = []
|
tooltip_parts = []
|
||||||
@@ -351,21 +376,24 @@ class CollectTab(QWidget):
|
|||||||
self._collect_stop_requested = False
|
self._collect_stop_requested = False
|
||||||
|
|
||||||
if outcome == "blocked":
|
if outcome == "blocked":
|
||||||
text = "采集未开始 · 检查未通过"
|
text = f"{self._collect_operation_label}未开始 · 检查未通过"
|
||||||
level = "warning"
|
level = "warning"
|
||||||
tooltip = "采集前检查未通过,请按弹窗提示处理"
|
tooltip = f"{self._collect_operation_label}前检查未通过,请按弹窗提示处理"
|
||||||
elif outcome == "cancelled":
|
elif outcome == "cancelled":
|
||||||
text = f"采集已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}"
|
text = f"{self._collect_operation_label}已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}"
|
||||||
level = "warning"
|
level = "warning"
|
||||||
tooltip = "本轮采集已停止"
|
tooltip = f"本轮{self._collect_operation_label}已停止"
|
||||||
elif outcome == "error":
|
elif outcome == "error":
|
||||||
text = "采集已结束 · 请查看运行日志"
|
text = f"{self._collect_operation_label}已结束 · 请查看运行日志"
|
||||||
level = "danger"
|
level = "danger"
|
||||||
tooltip = "采集异常结束,请查看下方采集运行日志"
|
if self._collect_operation_label == "采集":
|
||||||
|
tooltip = "采集异常结束,请查看下方采集运行日志"
|
||||||
|
else:
|
||||||
|
tooltip = f"{self._collect_operation_label}异常结束,请查看下方运行日志"
|
||||||
else:
|
else:
|
||||||
text = f"采集完成 · 总用时 {_format_collect_elapsed(total_elapsed)}"
|
text = f"{self._collect_operation_label}完成 · 总用时 {_format_collect_elapsed(total_elapsed)}"
|
||||||
level = "success"
|
level = "success"
|
||||||
tooltip = "本轮采集已经完成"
|
tooltip = f"本轮{self._collect_operation_label}已经完成"
|
||||||
self._collect_terminal_text = text
|
self._collect_terminal_text = text
|
||||||
self.collect_activity_label.setToolTip(tooltip)
|
self.collect_activity_label.setToolTip(tooltip)
|
||||||
self._set_collect_activity_style(level)
|
self._set_collect_activity_style(level)
|
||||||
@@ -379,9 +407,9 @@ class CollectTab(QWidget):
|
|||||||
def _append_collect_log(self, message):
|
def _append_collect_log(self, message):
|
||||||
self.run_log_view.appendPlainText(str(message))
|
self.run_log_view.appendPlainText(str(message))
|
||||||
|
|
||||||
def _load_latest_collect_run_log(self):
|
def _load_latest_collect_run_log(self, run_type="collect"):
|
||||||
try:
|
try:
|
||||||
logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path)
|
logs = db.list_run_logs(limit=1, run_type=run_type, path=self.db_path)
|
||||||
if not logs:
|
if not logs:
|
||||||
return
|
return
|
||||||
events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path)
|
events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path)
|
||||||
@@ -408,10 +436,10 @@ class CollectTab(QWidget):
|
|||||||
self._set_status(text, level="danger")
|
self._set_status(text, level="danger")
|
||||||
QTimer.singleShot(0, lambda: QMessageBox.warning(self, "导入采集", text))
|
QTimer.singleShot(0, lambda: QMessageBox.warning(self, "导入采集", text))
|
||||||
|
|
||||||
def _show_account_guide(self, message):
|
def _show_account_guide(self, message, operation_label="采集"):
|
||||||
full_message = (
|
full_message = (
|
||||||
f"{message}\n\n"
|
f"{message}\n\n"
|
||||||
"本轮采集已中止。\n"
|
f"本轮{operation_label}已中止。\n"
|
||||||
"请先到「账号管理」检查账号配置、Chrome 路径和登录状态。"
|
"请先到「账号管理」检查账号配置、Chrome 路径和登录状态。"
|
||||||
)
|
)
|
||||||
QMessageBox.warning(self, "账号未就绪", full_message)
|
QMessageBox.warning(self, "账号未就绪", full_message)
|
||||||
@@ -663,7 +691,11 @@ class CollectTab(QWidget):
|
|||||||
return self.batch_filter.currentData()
|
return self.batch_filter.currentData()
|
||||||
|
|
||||||
def _update_delete_batch_button(self):
|
def _update_delete_batch_button(self):
|
||||||
running = bool(self.collect_thread or self.write_back_thread)
|
running = bool(
|
||||||
|
self.collect_thread
|
||||||
|
or self.status_recheck_thread
|
||||||
|
or self.write_back_thread
|
||||||
|
)
|
||||||
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
|
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
|
||||||
|
|
||||||
def delete_current_batch(self, checked=False):
|
def delete_current_batch(self, checked=False):
|
||||||
@@ -716,6 +748,9 @@ class CollectTab(QWidget):
|
|||||||
QMessageBox.information(self, "删除批次", message)
|
QMessageBox.information(self, "删除批次", message)
|
||||||
|
|
||||||
def collect_old_data(self, checked=False):
|
def collect_old_data(self, checked=False):
|
||||||
|
if self._collect_operation_running() or self.write_back_thread is not None:
|
||||||
|
self._set_status("当前批处理尚未结束,请稍后再试")
|
||||||
|
return
|
||||||
tasks = list(self.model.tasks)
|
tasks = list(self.model.tasks)
|
||||||
if not tasks:
|
if not tasks:
|
||||||
self._set_status("没有可采集任务")
|
self._set_status("没有可采集任务")
|
||||||
@@ -749,6 +784,51 @@ class CollectTab(QWidget):
|
|||||||
self._start_collect_activity()
|
self._start_collect_activity()
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
def recheck_product_status(self, checked=False):
|
||||||
|
if self._collect_operation_running() or self.write_back_thread is not None:
|
||||||
|
self._set_status("当前批处理尚未结束,请稍后再试")
|
||||||
|
return
|
||||||
|
tasks = list(self.model.tasks)
|
||||||
|
if not tasks:
|
||||||
|
self._set_status("当前筛选结果没有可重新检测商品状态的任务")
|
||||||
|
return
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"重新检测商品状态",
|
||||||
|
"将重新打开当前筛选结果中的 {count} 条商品详情页,只读取并保存商品状态。\n\n"
|
||||||
|
"不会读取或覆盖旧标题、旧封面、新标题、新封面;不会改变任务阶段、结果、线上提交标记或 Excel。\n\n"
|
||||||
|
"确定开始重新检测吗?".format(count=len(tasks)),
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.Yes:
|
||||||
|
self._set_status("已取消重新检测商品状态")
|
||||||
|
return
|
||||||
|
worker = StatusRecheckWorker(
|
||||||
|
tasks,
|
||||||
|
db_path=self.db_path,
|
||||||
|
config=self.config,
|
||||||
|
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||||
|
)
|
||||||
|
activity_signal = getattr(worker, "activity", None)
|
||||||
|
if activity_signal is not None:
|
||||||
|
activity_signal.connect(self._on_collect_activity)
|
||||||
|
worker.progress.connect(self._on_status_recheck_progress)
|
||||||
|
worker.row_updated.connect(self._on_status_recheck_row_updated)
|
||||||
|
worker.log.connect(self._on_collect_log)
|
||||||
|
worker.failed.connect(self._on_status_recheck_failed)
|
||||||
|
worker.finished.connect(self._on_status_recheck_finished)
|
||||||
|
worker.cancelled.connect(self._on_status_recheck_cancelled)
|
||||||
|
self.run_log_view.clear()
|
||||||
|
thread = run_worker(worker, thread_name="StatusRecheckWorker", start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_status_recheck_thread(thread))
|
||||||
|
self.status_recheck_worker = worker
|
||||||
|
self.status_recheck_thread = thread
|
||||||
|
self._set_collect_running(True)
|
||||||
|
self._start_collect_activity("商品状态重检")
|
||||||
|
self._set_status(f"正在重新检测 {len(tasks)} 条商品状态...")
|
||||||
|
thread.start()
|
||||||
|
|
||||||
def _choose_collect_scope(self):
|
def _choose_collect_scope(self):
|
||||||
box = ProductStatusScopeDialog(
|
box = ProductStatusScopeDialog(
|
||||||
title="选择采集范围",
|
title="选择采集范围",
|
||||||
@@ -772,11 +852,12 @@ class CollectTab(QWidget):
|
|||||||
return box.choice()
|
return box.choice()
|
||||||
|
|
||||||
def stop_collect(self, checked=False):
|
def stop_collect(self, checked=False):
|
||||||
if self.collect_worker is not None:
|
worker = self.collect_worker or self.status_recheck_worker
|
||||||
self.collect_worker.cancel()
|
if worker is not None:
|
||||||
|
worker.cancel()
|
||||||
self._collect_stop_requested = True
|
self._collect_stop_requested = True
|
||||||
self._refresh_collect_activity()
|
self._refresh_collect_activity()
|
||||||
self._set_status("正在停止采集...")
|
self._set_status(f"正在停止{self._collect_operation_label}...")
|
||||||
|
|
||||||
def write_back_old_data(self, checked=False):
|
def write_back_old_data(self, checked=False):
|
||||||
batch_id = self._active_batch_id()
|
batch_id = self._active_batch_id()
|
||||||
@@ -786,6 +867,9 @@ class CollectTab(QWidget):
|
|||||||
self._start_write_back(batch_id)
|
self._start_write_back(batch_id)
|
||||||
|
|
||||||
def _start_write_back(self, batch_id, auto=False):
|
def _start_write_back(self, batch_id, auto=False):
|
||||||
|
if self._collect_operation_running():
|
||||||
|
self._set_status("当前批处理尚未结束,暂不能回写 Excel")
|
||||||
|
return False
|
||||||
if self.write_back_thread is not None:
|
if self.write_back_thread is not None:
|
||||||
self._set_status("Excel 回写正在进行...")
|
self._set_status("Excel 回写正在进行...")
|
||||||
return False
|
return False
|
||||||
@@ -832,6 +916,7 @@ class CollectTab(QWidget):
|
|||||||
self.import_button.setEnabled(not running)
|
self.import_button.setEnabled(not running)
|
||||||
self.refresh_button.setEnabled(not running)
|
self.refresh_button.setEnabled(not running)
|
||||||
self.collect_button.setEnabled(not running)
|
self.collect_button.setEnabled(not running)
|
||||||
|
self.status_recheck_button.setEnabled(not running)
|
||||||
self.write_back_button.setEnabled(not running)
|
self.write_back_button.setEnabled(not running)
|
||||||
self.stop_collect_button.setEnabled(running)
|
self.stop_collect_button.setEnabled(running)
|
||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
@@ -844,6 +929,7 @@ class CollectTab(QWidget):
|
|||||||
self.import_button.setEnabled(not running)
|
self.import_button.setEnabled(not running)
|
||||||
self.refresh_button.setEnabled(not running)
|
self.refresh_button.setEnabled(not running)
|
||||||
self.collect_button.setEnabled(not running)
|
self.collect_button.setEnabled(not running)
|
||||||
|
self.status_recheck_button.setEnabled(not running)
|
||||||
self.write_back_button.setEnabled(not running)
|
self.write_back_button.setEnabled(not running)
|
||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
self.shop_filter.setEnabled(not running)
|
self.shop_filter.setEnabled(not running)
|
||||||
@@ -858,6 +944,16 @@ class CollectTab(QWidget):
|
|||||||
self.collect_thread = None
|
self.collect_thread = None
|
||||||
self.collect_worker = None
|
self.collect_worker = None
|
||||||
|
|
||||||
|
def _forget_status_recheck_thread(self, thread):
|
||||||
|
if self.status_recheck_thread is thread:
|
||||||
|
if self._collect_elapsed_timer.isActive():
|
||||||
|
self._finish_collect_activity("error")
|
||||||
|
self.status_recheck_thread = None
|
||||||
|
self.status_recheck_worker = None
|
||||||
|
|
||||||
|
def _collect_operation_running(self):
|
||||||
|
return bool(self.collect_thread or self.status_recheck_thread)
|
||||||
|
|
||||||
def _forget_write_back_thread(self, thread):
|
def _forget_write_back_thread(self, thread):
|
||||||
if self.write_back_thread is thread:
|
if self.write_back_thread is thread:
|
||||||
self.write_back_thread = None
|
self.write_back_thread = None
|
||||||
@@ -880,6 +976,25 @@ class CollectTab(QWidget):
|
|||||||
def _on_collect_failed(self, task_id, error):
|
def _on_collect_failed(self, task_id, error):
|
||||||
self._set_status(f"任务 {task_id} 采集失败:{error}")
|
self._set_status(f"任务 {task_id} 采集失败:{error}")
|
||||||
|
|
||||||
|
def _on_status_recheck_progress(self, payload):
|
||||||
|
self._set_status(
|
||||||
|
"商品状态重检进度:{done}/{total},成功{rechecked},略过{skipped},失败{failed}".format(
|
||||||
|
done=payload.get("done", 0),
|
||||||
|
total=payload.get("total", 0),
|
||||||
|
rechecked=payload.get("rechecked", 0),
|
||||||
|
skipped=payload.get("skipped", 0),
|
||||||
|
failed=payload.get("failed", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_status_recheck_row_updated(self, task_id, fields):
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
|
||||||
|
def _on_status_recheck_failed(self, task_id, error):
|
||||||
|
self._set_status(f"任务 {task_id} 商品状态重检失败:{error}")
|
||||||
|
|
||||||
def _on_collect_finished(self, payload):
|
def _on_collect_finished(self, payload):
|
||||||
self._set_collect_running(False)
|
self._set_collect_running(False)
|
||||||
if payload.get("blocked"):
|
if payload.get("blocked"):
|
||||||
@@ -928,6 +1043,40 @@ class CollectTab(QWidget):
|
|||||||
return
|
return
|
||||||
self._set_status(message)
|
self._set_status(message)
|
||||||
|
|
||||||
|
def _on_status_recheck_finished(self, payload):
|
||||||
|
self._set_collect_running(False)
|
||||||
|
if payload.get("blocked"):
|
||||||
|
self._finish_collect_activity("blocked", payload)
|
||||||
|
elif payload.get("error"):
|
||||||
|
self._finish_collect_activity("error", payload)
|
||||||
|
else:
|
||||||
|
self._finish_collect_activity("finished", payload)
|
||||||
|
self.last_status_recheck_run_id = (
|
||||||
|
payload.get("run_id") or self.last_status_recheck_run_id
|
||||||
|
)
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
self._load_latest_collect_run_log("status_recheck")
|
||||||
|
if payload.get("blocked"):
|
||||||
|
self._show_status_recheck_blocked(payload)
|
||||||
|
return
|
||||||
|
message = "商品状态重检完成:成功{rechecked},略过{skipped},失败{failed}".format(
|
||||||
|
rechecked=payload.get("rechecked", 0),
|
||||||
|
skipped=payload.get("skipped", 0),
|
||||||
|
failed=payload.get("failed", 0),
|
||||||
|
)
|
||||||
|
counts = payload.get("product_status_counts") or {}
|
||||||
|
if counts:
|
||||||
|
message += ";正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}".format(
|
||||||
|
normal=counts.get("normal", 0),
|
||||||
|
unlisted=counts.get("unlisted", 0),
|
||||||
|
reviewing=counts.get("reviewing", 0),
|
||||||
|
unknown=counts.get("unknown", 0),
|
||||||
|
)
|
||||||
|
self._show_status_recheck_account_summary(payload, message)
|
||||||
|
self._set_status(message)
|
||||||
|
|
||||||
def _show_collect_blocked(self, payload):
|
def _show_collect_blocked(self, payload):
|
||||||
lines = ["采集前检查未通过。"]
|
lines = ["采集前检查未通过。"]
|
||||||
if payload.get("no_accounts"):
|
if payload.get("no_accounts"):
|
||||||
@@ -952,6 +1101,18 @@ class CollectTab(QWidget):
|
|||||||
)
|
)
|
||||||
self._show_account_guide("\n".join(lines))
|
self._show_account_guide("\n".join(lines))
|
||||||
|
|
||||||
|
def _show_status_recheck_blocked(self, payload):
|
||||||
|
lines = ["商品状态重检前检查未通过。"]
|
||||||
|
if payload.get("no_accounts"):
|
||||||
|
lines.append("当前没有配置账号。")
|
||||||
|
launch_failed = payload.get("launch_failed") or []
|
||||||
|
if launch_failed:
|
||||||
|
lines.append(
|
||||||
|
"以下账号 Chrome 启动失败:"
|
||||||
|
+ "、".join(self._account_label(item) for item in launch_failed)
|
||||||
|
)
|
||||||
|
self._show_account_guide("\n".join(lines), operation_label="商品状态重检")
|
||||||
|
|
||||||
def _show_collect_account_summary(self, payload, message):
|
def _show_collect_account_summary(self, payload, message):
|
||||||
launched = payload.get("launched_accounts") or []
|
launched = payload.get("launched_accounts") or []
|
||||||
reused = payload.get("reused_accounts") or []
|
reused = payload.get("reused_accounts") or []
|
||||||
@@ -1002,6 +1163,41 @@ class CollectTab(QWidget):
|
|||||||
else:
|
else:
|
||||||
QMessageBox.information(self, "采集完成", text)
|
QMessageBox.information(self, "采集完成", text)
|
||||||
|
|
||||||
|
def _show_status_recheck_account_summary(self, payload, message):
|
||||||
|
launched = payload.get("launched_accounts") or []
|
||||||
|
reused = payload.get("reused_accounts") or []
|
||||||
|
login_required = payload.get("login_required_accounts") or []
|
||||||
|
skip_counts = normalize_skip_reason_counts(
|
||||||
|
payload.get("skip_reason_counts"),
|
||||||
|
skipped_total=int(payload.get("skipped", 0) or 0),
|
||||||
|
)
|
||||||
|
lines = [message]
|
||||||
|
skip_summary = format_skip_reason_summary(
|
||||||
|
skip_counts,
|
||||||
|
skipped_total=int(payload.get("skipped", 0) or 0),
|
||||||
|
)
|
||||||
|
if skip_summary:
|
||||||
|
lines.append(skip_summary)
|
||||||
|
if launched:
|
||||||
|
lines.append(
|
||||||
|
"本轮已自动启动账号 Chrome:"
|
||||||
|
+ "、".join(self._account_label(item) for item in launched)
|
||||||
|
)
|
||||||
|
if login_required:
|
||||||
|
lines.append(
|
||||||
|
"以下账号需要补登录:"
|
||||||
|
+ "、".join(self._account_label(item) for item in login_required)
|
||||||
|
)
|
||||||
|
if login_required or skip_counts[LOGIN_REQUIRED] > 0:
|
||||||
|
lines.append("请到账号管理完成对应账号登录后,再重新检测商品状态。")
|
||||||
|
if launched or reused:
|
||||||
|
lines.append("检测结束后不会自动关闭账号 Chrome,请按需自行关闭。")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
if login_required or skip_counts[LOGIN_REQUIRED] > 0:
|
||||||
|
QMessageBox.warning(self, "商品状态重检完成", text)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "商品状态重检完成", text)
|
||||||
|
|
||||||
def _account_label(self, item):
|
def _account_label(self, item):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
name = item.get("account_name") or item.get("alias") or ""
|
name = item.get("account_name") or item.get("alias") or ""
|
||||||
@@ -1025,6 +1221,19 @@ class CollectTab(QWidget):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _on_status_recheck_cancelled(self, payload):
|
||||||
|
self._set_collect_running(False)
|
||||||
|
self._finish_collect_activity("cancelled", payload)
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
self._set_status(
|
||||||
|
"商品状态重检已停止:完成{done}/{total}".format(
|
||||||
|
done=payload.get("done", 0),
|
||||||
|
total=payload.get("total", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _on_write_back_failed(self, task_id, error, auto=False):
|
def _on_write_back_failed(self, task_id, error, auto=False):
|
||||||
message = f"Excel {'自动' if auto else ''}回写失败:{error}"
|
message = f"Excel {'自动' if auto else ''}回写失败:{error}"
|
||||||
if "被占用" in str(error):
|
if "被占用" in str(error):
|
||||||
|
|||||||
@@ -2738,6 +2738,383 @@ class CollectWorker(BaseWorker):
|
|||||||
def _elapsed_ms(self, started):
|
def _elapsed_ms(self, started):
|
||||||
return int((time.monotonic() - started) * 1000)
|
return int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusRecheckWorker(CollectWorker):
|
||||||
|
"""Re-read product status snapshots without changing task workflow fields."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tasks,
|
||||||
|
db_path=None,
|
||||||
|
config=None,
|
||||||
|
preflight=True,
|
||||||
|
diagnostic_log_dir=None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
tasks,
|
||||||
|
db_path=db_path,
|
||||||
|
config=config,
|
||||||
|
preflight=preflight,
|
||||||
|
diagnostic_log_dir=diagnostic_log_dir,
|
||||||
|
collect_scope=product_status.COLLECT_SCOPE_ALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
|
||||||
|
account_by_alias = {
|
||||||
|
str(account.alias).strip(): account
|
||||||
|
for account in account_rows
|
||||||
|
if str(account.alias).strip()
|
||||||
|
}
|
||||||
|
eligible = list(self.tasks)
|
||||||
|
batch_ids = self._batch_ids(eligible)
|
||||||
|
total = len(eligible)
|
||||||
|
rechecked = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
done = 0
|
||||||
|
login_skip_reasons = {}
|
||||||
|
login_required_accounts = {}
|
||||||
|
preflight_info = {}
|
||||||
|
skip_reason_counts = empty_skip_reason_counts()
|
||||||
|
product_status_counts = {
|
||||||
|
status: 0 for status in product_status.VALID_PRODUCT_STATUSES
|
||||||
|
}
|
||||||
|
|
||||||
|
self._run_id = self._create_run_log(eligible, batch_ids)
|
||||||
|
self._emit_activity("preflight_started", total=total, step="preflight")
|
||||||
|
self._log_run_event(
|
||||||
|
f"step=preflight result=start detail=商品状态重检开始 total={total}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.preflight:
|
||||||
|
blocked, preflight_info = self._preflight_prepare(
|
||||||
|
eligible,
|
||||||
|
account_rows,
|
||||||
|
account_by_alias,
|
||||||
|
)
|
||||||
|
if blocked:
|
||||||
|
self._log_preflight_blocked(blocked)
|
||||||
|
summary = self._summary(
|
||||||
|
ok=False,
|
||||||
|
total=total,
|
||||||
|
done=done,
|
||||||
|
collected=rechecked,
|
||||||
|
skipped=skipped,
|
||||||
|
failed=failed,
|
||||||
|
batch_ids=batch_ids,
|
||||||
|
blocked=True,
|
||||||
|
extra={
|
||||||
|
**blocked,
|
||||||
|
"rechecked": rechecked,
|
||||||
|
"skip_reason_counts": dict(skip_reason_counts),
|
||||||
|
"product_status_counts": dict(product_status_counts),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._finish_run_log("blocked", summary)
|
||||||
|
return summary
|
||||||
|
for item in preflight_info.get("logged_out") or []:
|
||||||
|
alias = str(item.get("alias") or "").strip()
|
||||||
|
reason = item.get("reason") or "账号未登录"
|
||||||
|
if alias:
|
||||||
|
login_skip_reasons[alias] = reason
|
||||||
|
login_required_accounts[alias] = item
|
||||||
|
self._log_run_event("step=preflight result=success detail=账号就绪检查完成")
|
||||||
|
else:
|
||||||
|
self._log_run_event(
|
||||||
|
"step=preflight result=skipped detail=测试模式跳过商品状态重检前检查",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, task in enumerate(eligible, start=1):
|
||||||
|
if self.should_cancel():
|
||||||
|
break
|
||||||
|
self._emit_activity(
|
||||||
|
"task_started",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="match_account",
|
||||||
|
)
|
||||||
|
account = account_by_alias.get(str(task.alias).strip())
|
||||||
|
if account is None:
|
||||||
|
skipped += 1
|
||||||
|
done += 1
|
||||||
|
skip_reason_counts[ALIAS_UNMATCHED] += 1
|
||||||
|
reason = "别名未匹配账号"
|
||||||
|
self._log_run_event(
|
||||||
|
"step=match_account result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
reason=reason,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="match_account",
|
||||||
|
result="skipped",
|
||||||
|
)
|
||||||
|
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
|
||||||
|
continue
|
||||||
|
|
||||||
|
alias = str(task.alias).strip()
|
||||||
|
if alias in login_skip_reasons:
|
||||||
|
skipped += 1
|
||||||
|
done += 1
|
||||||
|
skip_reason_counts[LOGIN_REQUIRED] += 1
|
||||||
|
reason = login_skip_reasons[alias]
|
||||||
|
self._log_run_event(
|
||||||
|
"step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
reason=reason,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="check_login",
|
||||||
|
result="skipped",
|
||||||
|
)
|
||||||
|
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="check_login",
|
||||||
|
)
|
||||||
|
status = self._confirmed_login_status(account, context="status_recheck", task=task)
|
||||||
|
if self._is_definitive_logged_out(status):
|
||||||
|
skipped += 1
|
||||||
|
done += 1
|
||||||
|
skip_reason_counts[LOGIN_REQUIRED] += 1
|
||||||
|
reason = self._midrun_login_skip_reason(status)
|
||||||
|
login_skip_reasons[alias] = reason
|
||||||
|
login_required_accounts[alias] = self._account_payload(account, reason)
|
||||||
|
self._log_run_event(
|
||||||
|
"step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
reason=reason,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="check_login",
|
||||||
|
result="skipped",
|
||||||
|
)
|
||||||
|
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
|
||||||
|
continue
|
||||||
|
if not status.get("logged_in"):
|
||||||
|
self._log_run_event(
|
||||||
|
"step=login_check result=uncertain detail=任务 {task_id} 商品 {item_id} 登录状态检测暂时不稳定,继续尝试读取商品状态: {detail}".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
detail=self._login_status_detail(status),
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
current_step = "read_product_status"
|
||||||
|
activity_result = "success"
|
||||||
|
|
||||||
|
def on_step(step):
|
||||||
|
nonlocal current_step
|
||||||
|
current_step = str(step)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step=current_step,
|
||||||
|
)
|
||||||
|
self._log_run_event(
|
||||||
|
"step={step} result=start detail=任务 {task_id} 商品 {item_id}".format(
|
||||||
|
step=current_step,
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = editor.recheck_product_status(
|
||||||
|
account,
|
||||||
|
{"item_id": task.item_id},
|
||||||
|
on_step=on_step,
|
||||||
|
)
|
||||||
|
detected_status = product_status.normalize_status(
|
||||||
|
result.get("product_status")
|
||||||
|
)
|
||||||
|
product_status_counts[detected_status] += 1
|
||||||
|
current_step = "db_write"
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="save_result",
|
||||||
|
)
|
||||||
|
self._log_run_event(
|
||||||
|
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存商品状态".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
db.set_product_status(
|
||||||
|
task.id,
|
||||||
|
detected_status,
|
||||||
|
result.get("product_status_note"),
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
rechecked += 1
|
||||||
|
elapsed_ms = self._elapsed_ms(started)
|
||||||
|
self.row_updated.emit(
|
||||||
|
task.id,
|
||||||
|
{
|
||||||
|
"product_status": detected_status,
|
||||||
|
"product_status_note": result.get("product_status_note"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log_run_event(
|
||||||
|
"step=db_write result=success detail=任务 {task_id} 商品 {item_id} 商品状态重检成功 elapsed_ms={elapsed_ms}".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
if result.get("close_target_confirmed") is False:
|
||||||
|
self._log_run_event(
|
||||||
|
"step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;状态结果已保存,继续处理后续任务".format(
|
||||||
|
task_id=task.id,
|
||||||
|
item_id=task.item_id,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
activity_result = "failed"
|
||||||
|
failed += 1
|
||||||
|
error = str(exc) or exc.__class__.__name__
|
||||||
|
safe_error = diagnostics.redact_log_text(error)
|
||||||
|
display_error = db.format_failure_error(safe_error, current_step)
|
||||||
|
elapsed_ms = self._elapsed_ms(started)
|
||||||
|
self.failed.emit(task.id, display_error)
|
||||||
|
self._log_run_event(
|
||||||
|
"step={step} result=failed detail=商品状态重检失败: {error} elapsed_ms={elapsed_ms}".format(
|
||||||
|
step=current_step,
|
||||||
|
error=safe_error,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
self._write_diagnostic_log(
|
||||||
|
"商品状态重检失败",
|
||||||
|
level="ERROR",
|
||||||
|
step=current_step,
|
||||||
|
task=task,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
payload={"error": safe_error},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
done += 1
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step=current_step,
|
||||||
|
result=activity_result,
|
||||||
|
)
|
||||||
|
self._emit_recheck_progress(done, total, rechecked, skipped, failed)
|
||||||
|
|
||||||
|
summary = self._summary(
|
||||||
|
ok=failed == 0,
|
||||||
|
total=total,
|
||||||
|
done=done,
|
||||||
|
collected=rechecked,
|
||||||
|
skipped=skipped,
|
||||||
|
failed=failed,
|
||||||
|
batch_ids=batch_ids,
|
||||||
|
extra={
|
||||||
|
**preflight_info,
|
||||||
|
"rechecked": rechecked,
|
||||||
|
"login_required_accounts": list(login_required_accounts.values()),
|
||||||
|
"skip_reason_counts": dict(skip_reason_counts),
|
||||||
|
"product_status_counts": dict(product_status_counts),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _emit_recheck_progress(self, done, total, rechecked, skipped, failed):
|
||||||
|
self.progress.emit(
|
||||||
|
{
|
||||||
|
"done": done,
|
||||||
|
"total": total,
|
||||||
|
"rechecked": rechecked,
|
||||||
|
"skipped": skipped,
|
||||||
|
"failed": failed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_run_log(self, eligible, batch_ids):
|
||||||
|
try:
|
||||||
|
return db.create_run_log(
|
||||||
|
"status_recheck",
|
||||||
|
dry_run=False,
|
||||||
|
total=len(eligible),
|
||||||
|
options={
|
||||||
|
"batch_ids": batch_ids,
|
||||||
|
"preflight": self.preflight,
|
||||||
|
"scope": "current_filters",
|
||||||
|
},
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _finish_run_log(self, status, summary):
|
||||||
|
if self._run_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
db.finish_run_log(
|
||||||
|
self._run_id,
|
||||||
|
status=status,
|
||||||
|
done=summary.get("done", 0),
|
||||||
|
success_count=summary.get("rechecked", 0),
|
||||||
|
skipped_count=summary.get("skipped", 0),
|
||||||
|
failed_count=summary.get("failed", 0),
|
||||||
|
summary_json=summary,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
class WriteBackWorker(BaseWorker):
|
class WriteBackWorker(BaseWorker):
|
||||||
"""Write Excel fields back in a background thread."""
|
"""Write Excel fields back in a background thread."""
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ def build_apply_plan(tasks, update_mode) -> dict:
|
|||||||
STATUS_UNLISTED: [],
|
STATUS_UNLISTED: [],
|
||||||
STATUS_REVIEWING: [],
|
STATUS_REVIEWING: [],
|
||||||
STATUS_UNKNOWN: [],
|
STATUS_UNKNOWN: [],
|
||||||
|
"unverified": [],
|
||||||
}
|
}
|
||||||
normal_candidates = []
|
normal_candidates = []
|
||||||
for task in base_candidates:
|
for task in base_candidates:
|
||||||
@@ -145,6 +146,8 @@ def build_apply_plan(tasks, update_mode) -> dict:
|
|||||||
status_counts[status] += 1
|
status_counts[status] += 1
|
||||||
if status == STATUS_NORMAL:
|
if status == STATUS_NORMAL:
|
||||||
normal_candidates.append(task)
|
normal_candidates.append(task)
|
||||||
|
elif status == STATUS_UNKNOWN and not _has_status_snapshot(task):
|
||||||
|
status_excluded["unverified"].append(task)
|
||||||
else:
|
else:
|
||||||
status_excluded[status].append(task)
|
status_excluded[status].append(task)
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ def build_apply_plan(tasks, update_mode) -> dict:
|
|||||||
"unlisted": status_excluded[STATUS_UNLISTED],
|
"unlisted": status_excluded[STATUS_UNLISTED],
|
||||||
"reviewing": status_excluded[STATUS_REVIEWING],
|
"reviewing": status_excluded[STATUS_REVIEWING],
|
||||||
"unknown": status_excluded[STATUS_UNKNOWN],
|
"unknown": status_excluded[STATUS_UNKNOWN],
|
||||||
|
"unverified": status_excluded["unverified"],
|
||||||
"missing_title": missing_title,
|
"missing_title": missing_title,
|
||||||
"missing_cover": missing_cover,
|
"missing_cover": missing_cover,
|
||||||
"status_scope_excluded": status_excluded_count,
|
"status_scope_excluded": status_excluded_count,
|
||||||
@@ -239,6 +243,10 @@ def _task_status(task) -> str:
|
|||||||
return normalize_status(_task_value(task, "product_status"))
|
return normalize_status(_task_value(task, "product_status"))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_status_snapshot(task) -> bool:
|
||||||
|
return bool(str(_task_value(task, "product_status") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
def _task_value(task, name, default=None):
|
def _task_value(task, name, default=None):
|
||||||
if isinstance(task, dict):
|
if isinstance(task, dict):
|
||||||
return task.get(name, default)
|
return task.get(name, default)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ imported → collected → generated → applied
|
|||||||
- `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。
|
- `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。
|
||||||
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
||||||
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
||||||
- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、仅重检商品状态、改标题、换封面、点更新。
|
||||||
- `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。
|
- `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。
|
||||||
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。
|
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。
|
||||||
- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
|
- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
|
||||||
@@ -356,7 +356,7 @@ CREATE TABLE run_log_events (
|
|||||||
- `account_name` 仅展示/参考;匹配以 `alias` 为准。
|
- `account_name` 仅展示/参考;匹配以 `alias` 为准。
|
||||||
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
|
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
|
||||||
- `row_key` 防止同一批次内重复导入同一行。
|
- `row_key` 防止同一批次内重复导入同一行。
|
||||||
- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。历史 NULL 与非法值按 `unknown` 处理。
|
- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。历史 NULL 与非法值在状态计算中按 `unknown` 处理,但③预检要把原始 NULL/空值单列为「尚未检测商品状态」,与实际已检测的 `unknown` 分开提示。
|
||||||
- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
|
- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
|
||||||
- `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。
|
- `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。
|
||||||
- `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
|
- `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
|
||||||
@@ -407,7 +407,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
### 6.0 GUI 线程模型(PySide6)
|
### 6.0 GUI 线程模型(PySide6)
|
||||||
|
|
||||||
- 主线程只运行 `QApplication`、窗口、表格、弹窗和状态刷新;不得在主线程执行 CDP、AI、Excel 回写、图片下载等耗时任务。
|
- 主线程只运行 `QApplication`、窗口、表格、弹窗和状态刷新;不得在主线程执行 CDP、AI、Excel 回写、图片下载等耗时任务。
|
||||||
- 每类耗时流程封装为 `QObject` worker:`CollectWorker`、`GenerateWorker`、`ApplyWorker`、`ImportWorker`、`WriteBackWorker`。
|
- 每类耗时流程封装为 `QObject` worker:`CollectWorker`、`StatusRecheckWorker`、`GenerateWorker`、`ApplyWorker`、`ImportWorker`、`WriteBackWorker`。
|
||||||
- Worker 通过 signal 向 GUI 汇报:`progress`、`row_updated`、`log`、`failed`、`finished`、`cancelled`;GUI 槽函数只做 UI 刷新和按钮状态切换。
|
- Worker 通过 signal 向 GUI 汇报:`progress`、`row_updated`、`log`、`failed`、`finished`、`cancelled`;GUI 槽函数只做 UI 刷新和按钮状态切换。
|
||||||
- Worker 不直接操作任何 Qt widget,不弹窗;需要用户确认的动作(如 ③ 开始更新确认)必须在主线程先完成,再启动 worker。
|
- Worker 不直接操作任何 Qt widget,不弹窗;需要用户确认的动作(如 ③ 开始更新确认)必须在主线程先完成,再启动 worker。
|
||||||
- `停止` 使用协作式取消:GUI 调用 worker 的 cancel 标记;worker 在任务间/重试前检查,取消未开始项,正在执行的单条任务跑到安全边界后结束。
|
- `停止` 使用协作式取消:GUI 调用 worker 的 cancel 标记;worker 在任务间/重试前检查,取消未开始项,正在执行的单条任务跑到安全边界后结束。
|
||||||
@@ -422,6 +422,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。
|
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。
|
||||||
- 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`,没有 warning 为 `normal`,未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。
|
- 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`,没有 warning 为 `normal`,未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。
|
||||||
- 每次点击采集先在主线程选择本轮范围,策略值为 `normal_only/all`,默认 `normal_only`,不跨轮记忆。两种范围都会逐条重新打开页面检测并独立保存状态,不能用旧数据库状态预过滤;`normal_only` 仅正常商品读取标题和封面,其他三类状态记为业务略过并保留旧内容,`all` 则四类状态均走现有采集流程。范围略过不是技术失败,汇总分别统计状态和略过数。
|
- 每次点击采集先在主线程选择本轮范围,策略值为 `normal_only/all`,默认 `normal_only`,不跨轮记忆。两种范围都会逐条重新打开页面检测并独立保存状态,不能用旧数据库状态预过滤;`normal_only` 仅正常商品读取标题和封面,其他三类状态记为业务略过并保留旧内容,`all` 则四类状态均走现有采集流程。范围略过不是技术失败,汇总分别统计状态和略过数。
|
||||||
|
- ①另有「重新检测商品状态」独立 Worker,面向当前筛选结果中的历史阶段任务。它复用账号就绪、登录检测、后台详情页 tab、状态 DOM 读取与自动新建 tab 关闭规则,但只调用 `editor.recheck_product_status()` 和 `db.set_product_status()`;不得调用 `db.set_collected()`、`mark_running()`、`mark_failed()`、Excel 回写或生成/更新逻辑。技术失败不覆盖已有状态快照,也不改变 `stage/status/committed`。该流程独立记录 `run_type=status_recheck`,汇总成功、略过、失败和四类状态计数。
|
||||||
|
|
||||||
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
|
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
|
||||||
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
|
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
|
||||||
@@ -476,7 +477,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
### 6.3 应用更新(③ Tab)
|
### 6.3 应用更新(③ Tab)
|
||||||
|
|
||||||
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
|
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
|
||||||
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、`unknown` 与历史 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 Chrome、不写其失败状态、Excel 或运行内 worker 统计。无可执行项时,优先展示真实商品状态原因;只有没有状态异常时才沿用「更新内容未生成」文案。
|
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、已检测 `unknown` 与历史 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。原始 NULL/空状态要单列为「尚未检测商品状态」并引导①重检,已检测但不能确认的 `unknown` 保持「状态未知」。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 Chrome、不写其失败状态、Excel 或运行内 worker 统计。无可执行项时,优先展示真实商品状态原因;只有没有状态异常时才沿用「更新内容未生成」文案。
|
||||||
- 计划指纹覆盖 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式`;用户确认后重新计算,任何变化均废弃旧计划并要求重新开始。`ApplyWorker` 仍在执行层再次验证 `product_status=normal`,防止直接构造 Worker 绕过 GUI 预检。①、②本轮的“所有状态”范围选择不向③传递更新授权。
|
- 计划指纹覆盖 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式`;用户确认后重新计算,任何变化均废弃旧计划并要求重新开始。`ApplyWorker` 仍在执行层再次验证 `product_status=normal`,防止直接构造 Worker 绕过 GUI 预检。①、②本轮的“所有状态”范围选择不向③传递更新授权。
|
||||||
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
|
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
|
||||||
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
|
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
|
||||||
|
|||||||
+5
-1
@@ -266,6 +266,8 @@ open_product(account, item_id) -> CDP # 连端口、导航商品页、等
|
|||||||
|
|
||||||
# 采集(只读)
|
# 采集(只读)
|
||||||
read_product_status(cdp) -> dict # {product_status, product_status_note, product_status_error};仅读 EDS warning,不保存 HTML
|
read_product_status(cdp) -> dict # {product_status, product_status_note, product_status_error};仅读 EDS warning,不保存 HTML
|
||||||
|
recheck_product_status(account, task, on_step=None) -> dict
|
||||||
|
# 只打开详情页读取状态;状态 DOM 读取失败抛中文异常,不把 unknown 快照返回给调用方;结束时沿用只读 tab 关闭规则
|
||||||
read_title(cdp) -> str
|
read_title(cdp) -> str
|
||||||
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
|
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
|
||||||
download_cover(src, out_path) -> str # 下载旧封面到本地
|
download_cover(src, out_path) -> str # 下载旧封面到本地
|
||||||
@@ -290,7 +292,7 @@ apply_task(account, task, close_success_tab=False) -> dict
|
|||||||
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
||||||
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。
|
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。
|
||||||
|
|
||||||
- `read_product_status()` 在读取标题/封面前读取 `.eds-alert.eds-alert--warning` 下的 `.eds-alert-title/.eds-alert-desc`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`,无 warning → `normal`,未识别 warning、DOM 异常或非法响应 → `unknown`;多横幅按 DOM 顺序取第一个白名单命中,不依赖 `data-v-*`,只返回归一化 note,不保存 HTML。状态读取异常不阻断 `collect()`,由调用方写脱敏诊断日志和 `unknown` 快照。
|
- `read_product_status()` 在读取标题/封面前读取 `.eds-alert.eds-alert--warning` 下的 `.eds-alert-title/.eds-alert-desc`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`,无 warning → `normal`,未识别 warning、DOM 异常或非法响应 → `unknown`;多横幅按 DOM 顺序取第一个白名单命中,不依赖 `data-v-*`,只返回归一化 note,不保存 HTML。状态读取异常不阻断 `collect()`,由调用方写脱敏诊断日志和 `unknown` 快照;`recheck_product_status()` 则把读取异常作为技术失败,不覆盖既有快照。
|
||||||
|
|
||||||
- `collect()` 先读取状态再决定是否读取标题/封面。`task.collection_scope=normal_only` 时,未上架、审核中、状态未知返回 `collection_skipped=True` 和中文原因,不下载封面、不覆盖旧内容;`all` 时四类状态都继续采集。两种策略都重新读取页面状态,不能用历史状态预过滤。结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。
|
- `collect()` 先读取状态再决定是否读取标题/封面。`task.collection_scope=normal_only` 时,未上架、审核中、状态未知返回 `collection_skipped=True` 和中文原因,不下载封面、不覆盖旧内容;`all` 时四类状态都继续采集。两种策略都重新读取页面状态,不能用历史状态预过滤。结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。
|
||||||
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
|
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
|
||||||
@@ -461,6 +463,7 @@ class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响
|
|||||||
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
|
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
|
||||||
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
||||||
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
|
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
|
||||||
|
class StatusRecheckWorker(BaseWorker) # ① 后台状态重检:当前筛选任务 + 账号预检 -> editor.recheck_product_status -> 仅 set_product_status
|
||||||
class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度
|
class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度
|
||||||
class ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
class ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
||||||
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||||||
@@ -522,6 +525,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
|||||||
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
|
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
|
||||||
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
|
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
|
||||||
- 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认按钮为“采集架上商品”(稳定策略 `normal_only`,仅继续采集检测结果为正常的商品),扩展按钮为“采集全部商品”(`all`,还包括未上架、审核中和状态未知商品);后者是警示橙色的扩大范围操作而非删除操作。对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
- 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认按钮为“采集架上商品”(稳定策略 `normal_only`,仅继续采集检测结果为正常的商品),扩展按钮为“采集全部商品”(`all`,还包括未上架、审核中和状态未知商品);后者是警示橙色的扩大范围操作而非删除操作。对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
||||||
|
- 「重新检测商品状态」不使用 `CollectWorker` 的阶段过滤,接收当前筛选结果的历史任务,确认后创建 `StatusRecheckWorker`。逐条只调用 `editor.recheck_product_status()` 并在成功后 `db.set_product_status()`;不得调用 `set_collected/mark_running/mark_failed/mark_skipped`,不改 `stage/status/committed`、标题、封面或 Excel。详情页、登录或 DOM 技术失败只写脱敏 `run_type=status_recheck` / 本地诊断日志并保留原快照。③预检对状态原始 NULL/空值显示「尚未检测商品状态」并引导此入口;已写入 `unknown` 的记录继续显示「状态未知」。
|
||||||
- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
||||||
- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。
|
- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。
|
||||||
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
|
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
|
||||||
|
|||||||
+2
-1
@@ -73,6 +73,7 @@
|
|||||||
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
|
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
|
||||||
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
|
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
|
||||||
- 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。
|
- 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。
|
||||||
|
- 「重新检测商品状态」作用于**当前筛选结果**,可处理历史 `imported/collected/generated/applied/failed/skipped` 任务。确认框必须说明本操作只重新打开详情页并写 `product_status/product_status_note/product_status_at`,不读取或覆盖新旧标题、新旧封面,不改变 `stage/status/committed`,不回写 Excel、不生成内容、不提交线上。重检使用独立 `run_type=status_recheck` 运行日志;账号、登录、CDP 或 DOM 技术失败只记录脱敏错误并保留已有状态快照和主流程状态。
|
||||||
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
|
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
|
||||||
|
|
||||||
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
|
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
|
- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
|
||||||
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
|
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
|
||||||
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
|
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
|
||||||
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、`unknown` 及历史 NULL 一律排除,不传给 `ApplyWorker`。有可执行记录时,状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。
|
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、已检测的 `unknown` 及历史 NULL 一律排除,不传给 `ApplyWorker`。历史 NULL/空值在中文提示中单列为「尚未检测商品状态」,并明确引导到①点击「重新检测商品状态」;已检测但无法确认的 `unknown` 仍显示「状态未知」。有可执行记录时,状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。
|
||||||
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
|
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
|
||||||
- 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。
|
- 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。
|
||||||
- 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
- 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: T-666
|
id: T-666
|
||||||
title: 历史批次商品状态重检与更新引导
|
title: 历史批次商品状态重检与更新引导
|
||||||
status: TODO
|
status: DONE
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-662a, T-662b, T-662d]
|
deps: [T-662a, T-662b, T-662d]
|
||||||
created: 2026-07-18
|
created: 2026-07-18
|
||||||
@@ -55,4 +55,8 @@ git diff --check
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 待实现。
|
- 新增 `StatusRecheckWorker` 与①「重新检测商品状态」入口:针对当前筛选的历史任务复用账号/登录/只读详情页状态检测,仅成功调用 `db.set_product_status()`;不改任务阶段、处理结果、标题、封面、提交标记或 Excel,并记录独立 `run_type=status_recheck`。
|
||||||
|
- 新增 `editor.recheck_product_status()`,状态 DOM 读取异常作为技术失败处理,仍按只读 tab 规则关闭本轮自动新建页面;失败保留既有状态快照和主流程字段。
|
||||||
|
- ③预检将原始 NULL/空状态单列为「尚未检测商品状态」,真实 `unknown` 继续显示「状态未知」,前者直接引导①重检;两类记录均保持安全拦截。
|
||||||
|
- 已更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/api.md`,并补 worker、GUI、状态规划及编辑器测试。
|
||||||
|
- 验证通过:`py -3.10 -m unittest discover -s tests`(624 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
|
||||||
|
|||||||
@@ -941,6 +941,48 @@ class EditorLoginTests(unittest.TestCase):
|
|||||||
self.assertIsNone(result["product_status_note"])
|
self.assertIsNone(result["product_status_note"])
|
||||||
self.assertIn("状态读取失败", result["product_status_error"])
|
self.assertIn("状态读取失败", result["product_status_error"])
|
||||||
|
|
||||||
|
def test_recheck_product_status_reads_only_status_and_closes_new_tab(self):
|
||||||
|
cdp = FakeProductCDP("ws-status-recheck", alerts=[])
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
|
||||||
|
"app.editor.read_title"
|
||||||
|
) as read_title, mock.patch("app.editor.read_cover_src") as read_cover_src, mock.patch(
|
||||||
|
"app.editor.download_cover"
|
||||||
|
) as download_cover:
|
||||||
|
result = editor.recheck_product_status(
|
||||||
|
{"debug_port": 9222},
|
||||||
|
{"item_id": "51100639510"},
|
||||||
|
on_step=steps.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("normal", result["product_status"])
|
||||||
|
self.assertIsNone(result["close_target_confirmed"])
|
||||||
|
self.assertEqual(["read_product_status"], steps)
|
||||||
|
self.assertTrue(cdp.closed)
|
||||||
|
read_title.assert_not_called()
|
||||||
|
read_cover_src.assert_not_called()
|
||||||
|
download_cover.assert_not_called()
|
||||||
|
|
||||||
|
def test_recheck_product_status_does_not_return_unknown_snapshot_on_read_error(self):
|
||||||
|
cdp = FakeProductCDP("ws-status-recheck", alerts=[])
|
||||||
|
|
||||||
|
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
|
||||||
|
"app.editor.read_product_status",
|
||||||
|
return_value={
|
||||||
|
"product_status": "unknown",
|
||||||
|
"product_status_note": None,
|
||||||
|
"product_status_error": "CDP 页面读取失败",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
with self.assertRaises(editor.EditorError):
|
||||||
|
editor.recheck_product_status(
|
||||||
|
{"debug_port": 9222},
|
||||||
|
{"item_id": "51100639510"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(cdp.closed)
|
||||||
|
|
||||||
def test_collect_reads_product_status_before_title_and_returns_snapshot(self):
|
def test_collect_reads_product_status_before_title_and_returns_snapshot(self):
|
||||||
cdp = FakeProductCDP("ws-new", alerts=[])
|
cdp = FakeProductCDP("ws-new", alerts=[])
|
||||||
steps = []
|
steps = []
|
||||||
|
|||||||
+70
-1
@@ -7079,7 +7079,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
message = warning.call_args[0][2]
|
message = warning.call_args[0][2]
|
||||||
self.assertIn("未上架:1 条", message)
|
self.assertIn("未上架:1 条", message)
|
||||||
self.assertIn("审核中:1 条", message)
|
self.assertIn("审核中:1 条", message)
|
||||||
self.assertIn("状态未知:1 条", message)
|
self.assertIn("尚未检测商品状态:1 条", message)
|
||||||
|
self.assertIn("点击「重新检测商品状态」", message)
|
||||||
self.assertNotIn("缺少新标题", message)
|
self.assertNotIn("缺少新标题", message)
|
||||||
question.assert_not_called()
|
question.assert_not_called()
|
||||||
run_worker.assert_not_called()
|
run_worker.assert_not_called()
|
||||||
@@ -8374,6 +8375,74 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_status_recheck_uses_current_generated_filter_without_changing_task_fields(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
db.init_db(cfg["db_path"])
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 2,
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639510",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 3,
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639511",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
for task in tasks:
|
||||||
|
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||||
|
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||||
|
tab = CollectTab(config=cfg)
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("generated"))
|
||||||
|
|
||||||
|
class FakeWorker:
|
||||||
|
def __init__(self, worker_tasks, **kwargs):
|
||||||
|
self.tasks = list(worker_tasks)
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.progress = DummySignal()
|
||||||
|
self.row_updated = DummySignal()
|
||||||
|
self.log = DummySignal()
|
||||||
|
self.failed = DummySignal()
|
||||||
|
self.finished = DummySignal()
|
||||||
|
self.cancelled = DummySignal()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
thread = FakeThread()
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.QMessageBox.question",
|
||||||
|
return_value=gui.QMessageBox.Yes,
|
||||||
|
) as question, mock.patch(
|
||||||
|
"app.gui.StatusRecheckWorker", FakeWorker
|
||||||
|
), mock.patch("app.gui.run_worker", return_value=thread):
|
||||||
|
tab.recheck_product_status()
|
||||||
|
|
||||||
|
self.assertTrue(thread.started)
|
||||||
|
self.assertEqual([task.id for task in tasks], [task.id for task in tab.status_recheck_worker.tasks])
|
||||||
|
self.assertEqual(cfg["db_path"], tab.status_recheck_worker.kwargs["db_path"])
|
||||||
|
self.assertIn("不会读取或覆盖旧标题", question.call_args[0][2])
|
||||||
|
self.assertFalse(tab.collect_button.isEnabled())
|
||||||
|
self.assertFalse(tab.status_recheck_button.isEnabled())
|
||||||
|
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
self.assertTrue(all(task.stage == "generated" for task in unchanged))
|
||||||
|
self.assertTrue(all(task.product_status is None for task in unchanged))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_collect_scope_dialog_uses_safe_default_and_warning_all_status_choice(self):
|
def test_collect_scope_dialog_uses_safe_default_and_warning_all_status_choice(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
tab = CollectTab(config=self.make_config(temp_dir))
|
tab = CollectTab(config=self.make_config(temp_dir))
|
||||||
|
|||||||
@@ -190,10 +190,35 @@ class ProductStatusTests(unittest.TestCase):
|
|||||||
self.assertEqual([1], [task.id for task in plan["executable"]])
|
self.assertEqual([1], [task.id for task in plan["executable"]])
|
||||||
self.assertEqual([2], [task.id for task in plan["unlisted"]])
|
self.assertEqual([2], [task.id for task in plan["unlisted"]])
|
||||||
self.assertEqual([3], [task.id for task in plan["reviewing"]])
|
self.assertEqual([3], [task.id for task in plan["reviewing"]])
|
||||||
self.assertEqual([4], [task.id for task in plan["unknown"]])
|
self.assertEqual([], [task.id for task in plan["unknown"]])
|
||||||
|
self.assertEqual([4], [task.id for task in plan["unverified"]])
|
||||||
self.assertEqual([5], [task.id for task in plan["missing_title"]])
|
self.assertEqual([5], [task.id for task in plan["missing_title"]])
|
||||||
self.assertEqual([5], [task.id for task in plan["missing_cover"]])
|
self.assertEqual([5], [task.id for task in plan["missing_cover"]])
|
||||||
self.assertEqual(3, plan["status_scope_excluded"])
|
self.assertEqual(3, plan["status_scope_excluded"])
|
||||||
self.assertEqual(1, plan["content_scope_excluded"])
|
self.assertEqual(1, plan["content_scope_excluded"])
|
||||||
self.assertEqual(4, plan["scope_excluded"])
|
self.assertEqual(4, plan["scope_excluded"])
|
||||||
self.assertNotEqual(plan["fingerprint"], changed["fingerprint"])
|
self.assertNotEqual(plan["fingerprint"], changed["fingerprint"])
|
||||||
|
|
||||||
|
def test_apply_plan_separates_missing_status_from_confirmed_unknown(self):
|
||||||
|
tasks = [
|
||||||
|
SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
updated_at="2026-07-18T10:00:00",
|
||||||
|
product_status=None,
|
||||||
|
new_title="新标题",
|
||||||
|
new_cover_path="new.jpg",
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id=2,
|
||||||
|
updated_at="2026-07-18T10:00:01",
|
||||||
|
product_status="unknown",
|
||||||
|
new_title="新标题",
|
||||||
|
new_cover_path="new.jpg",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
plan = product_status.build_apply_plan(tasks, "cover")
|
||||||
|
|
||||||
|
self.assertEqual([1], [task.id for task in plan["unverified"]])
|
||||||
|
self.assertEqual([2], [task.id for task in plan["unknown"]])
|
||||||
|
self.assertEqual(2, plan["status_scope_excluded"])
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.gui.workers import (
|
|||||||
ProductSuiteGenerateWorker,
|
ProductSuiteGenerateWorker,
|
||||||
ProductSuiteHistoryExportWorker,
|
ProductSuiteHistoryExportWorker,
|
||||||
CollectWorker,
|
CollectWorker,
|
||||||
|
StatusRecheckWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -280,6 +281,192 @@ class WorkerTests(unittest.TestCase):
|
|||||||
all(task.stage == "collected" for task in db.list_tasks(batch_id=batch_id, path=db_path))
|
all(task.stage == "collected" for task in db.list_tasks(batch_id=batch_id, path=db_path))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_status_recheck_worker_updates_only_status_for_historical_generated_tasks(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=db_path)
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "Sheet1",
|
||||||
|
"source_row": index,
|
||||||
|
"account_name": "店铺",
|
||||||
|
"alias": "alias",
|
||||||
|
"item_id": str(51100639700 + index),
|
||||||
|
}
|
||||||
|
for index in range(2, 6)
|
||||||
|
],
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
tasks = db.list_tasks(batch_id=batch_id, path=db_path)
|
||||||
|
for index, task in enumerate(tasks):
|
||||||
|
db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=db_path)
|
||||||
|
db.set_generated(task.id, f"新标题{index}", f"new-{index}.jpg", path=db_path)
|
||||||
|
db.set_applied(tasks[-1].id, committed=True, path=db_path)
|
||||||
|
statuses = ["normal", "unlisted", "reviewing", "unknown"]
|
||||||
|
account = SimpleNamespace(alias="alias", account_name="店铺", debug_port=9222)
|
||||||
|
|
||||||
|
def fake_recheck(_account, task, on_step=None):
|
||||||
|
on_step("read_product_status")
|
||||||
|
status = statuses.pop(0)
|
||||||
|
return {
|
||||||
|
"product_status": status,
|
||||||
|
"product_status_note": f"{status} 提示",
|
||||||
|
"close_target_confirmed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.workers.accounts.list_accounts", return_value=[account]
|
||||||
|
), mock.patch(
|
||||||
|
"app.gui.workers.accounts.detect_login",
|
||||||
|
return_value={"logged_in": True, "reason": None},
|
||||||
|
), mock.patch(
|
||||||
|
"app.gui.workers.editor.recheck_product_status",
|
||||||
|
side_effect=fake_recheck,
|
||||||
|
):
|
||||||
|
summary = StatusRecheckWorker(
|
||||||
|
tasks,
|
||||||
|
db_path=db_path,
|
||||||
|
preflight=False,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
self.assertEqual(4, summary["rechecked"])
|
||||||
|
self.assertEqual(0, summary["failed"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"normal": 1, "unlisted": 1, "reviewing": 1, "unknown": 1},
|
||||||
|
summary["product_status_counts"],
|
||||||
|
)
|
||||||
|
refreshed = db.list_tasks(batch_id=batch_id, path=db_path)
|
||||||
|
self.assertEqual(
|
||||||
|
["generated", "generated", "generated", "applied"],
|
||||||
|
[task.stage for task in refreshed],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["success", "success", "success", "success"],
|
||||||
|
[task.status for task in refreshed],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[f"旧标题{index}" for index in range(4)],
|
||||||
|
[task.old_title for task in refreshed],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[f"新标题{index}" for index in range(4)],
|
||||||
|
[task.new_title for task in refreshed],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[f"new-{index}.jpg" for index in range(4)],
|
||||||
|
[task.new_cover_path for task in refreshed],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["normal", "unlisted", "reviewing", "unknown"],
|
||||||
|
[task.product_status for task in refreshed],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(task.product_status_at for task in refreshed))
|
||||||
|
run_log = db.list_run_logs(limit=1, run_type="status_recheck", path=db_path)[0]
|
||||||
|
self.assertEqual("done", run_log.status)
|
||||||
|
self.assertEqual(4, run_log.success_count)
|
||||||
|
|
||||||
|
def test_status_recheck_worker_failure_keeps_existing_snapshot_and_workflow(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=db_path)
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "Sheet1",
|
||||||
|
"source_row": 2,
|
||||||
|
"account_name": "店铺",
|
||||||
|
"alias": "alias",
|
||||||
|
"item_id": "51100639801",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
task = db.list_tasks(batch_id=batch_id, path=db_path)[0]
|
||||||
|
db.set_collected(
|
||||||
|
task.id,
|
||||||
|
"旧标题",
|
||||||
|
"old.jpg",
|
||||||
|
product_status_value="normal",
|
||||||
|
product_status_note="历史快照",
|
||||||
|
product_status_at="2026-07-01T10:00:00",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
db.set_generated(task.id, "新标题", "new.jpg", path=db_path)
|
||||||
|
task = db.get_task(task.id, path=db_path)
|
||||||
|
account = SimpleNamespace(alias="alias", account_name="店铺", debug_port=9222)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.workers.accounts.list_accounts", return_value=[account]
|
||||||
|
), mock.patch(
|
||||||
|
"app.gui.workers.accounts.detect_login",
|
||||||
|
return_value={"logged_in": True, "reason": None},
|
||||||
|
), mock.patch(
|
||||||
|
"app.gui.workers.editor.recheck_product_status",
|
||||||
|
side_effect=RuntimeError("CDP 商品页读取失败"),
|
||||||
|
):
|
||||||
|
summary = StatusRecheckWorker(
|
||||||
|
[task],
|
||||||
|
db_path=db_path,
|
||||||
|
preflight=False,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
refreshed = db.get_task(task.id, path=db_path)
|
||||||
|
self.assertEqual(1, summary["failed"])
|
||||||
|
self.assertEqual("generated", refreshed.stage)
|
||||||
|
self.assertEqual("success", refreshed.status)
|
||||||
|
self.assertEqual("normal", refreshed.product_status)
|
||||||
|
self.assertEqual("历史快照", refreshed.product_status_note)
|
||||||
|
self.assertEqual("2026-07-01T10:00:00", refreshed.product_status_at)
|
||||||
|
self.assertEqual("新标题", refreshed.new_title)
|
||||||
|
self.assertEqual("new.jpg", refreshed.new_cover_path)
|
||||||
|
|
||||||
|
def test_status_recheck_worker_cancel_keeps_generated_task_unchanged(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=db_path)
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "Sheet1",
|
||||||
|
"source_row": 2,
|
||||||
|
"account_name": "店铺",
|
||||||
|
"alias": "alias",
|
||||||
|
"item_id": "51100639802",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
task = db.list_tasks(batch_id=batch_id, path=db_path)[0]
|
||||||
|
db.set_collected(task.id, "旧标题", "old.jpg", path=db_path)
|
||||||
|
db.set_generated(task.id, "新标题", "new.jpg", path=db_path)
|
||||||
|
task = db.get_task(task.id, path=db_path)
|
||||||
|
worker = StatusRecheckWorker([task], db_path=db_path, preflight=False)
|
||||||
|
worker.cancel()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.workers.accounts.list_accounts", return_value=[]), mock.patch(
|
||||||
|
"app.gui.workers.editor.recheck_product_status"
|
||||||
|
) as recheck:
|
||||||
|
summary = worker.execute()
|
||||||
|
|
||||||
|
refreshed = db.get_task(task.id, path=db_path)
|
||||||
|
self.assertEqual(0, summary["done"])
|
||||||
|
self.assertEqual(0, summary["rechecked"])
|
||||||
|
self.assertEqual("generated", refreshed.stage)
|
||||||
|
self.assertEqual("success", refreshed.status)
|
||||||
|
recheck.assert_not_called()
|
||||||
|
run_log = db.list_run_logs(limit=1, run_type="status_recheck", path=db_path)[0]
|
||||||
|
self.assertEqual("cancelled", run_log.status)
|
||||||
|
|
||||||
def test_run_worker_rejects_plain_object(self):
|
def test_run_worker_rejects_plain_object(self):
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(TypeError):
|
||||||
run_worker(object(), start=False)
|
run_worker(object(), start=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user