feat(collect): choose product status scope

This commit is contained in:
chengma
2026-07-18 16:46:59 +08:00
parent 0cf23db403
commit 4658a1ca61
11 changed files with 420 additions and 41 deletions
+15
View File
@@ -1166,6 +1166,21 @@ def collect(account, task, on_step=None) -> dict:
try: try:
_notify_collect_step(on_step, "read_product_status") _notify_collect_step(on_step, "read_product_status")
status_snapshot = read_product_status(cdp) status_snapshot = read_product_status(cdp)
collect_scope = product_status.normalize_collect_scope(
_get(task, "collection_scope")
)
if not product_status.should_collect_content(
status_snapshot.get("product_status"),
collect_scope,
):
result = {
**status_snapshot,
"collection_skipped": True,
"collection_skip_reason": product_status.collect_skip_reason(
status_snapshot.get("product_status")
),
}
else:
_notify_collect_step(on_step, "read_title") _notify_collect_step(on_step, "read_title")
old_title = read_title(cdp) old_title = read_title(cdp)
_notify_collect_step(on_step, "read_cover") _notify_collect_step(on_step, "read_cover")
+60 -3
View File
@@ -8,6 +8,7 @@ from ...collect_skip import (
format_skip_reason_summary, format_skip_reason_summary,
normalize_skip_reason_counts, normalize_skip_reason_counts,
) )
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, WriteBackWorker as _RealWriteBackWorker
@@ -28,6 +29,7 @@ COLLECT_ACTIVITY_STEP_LABELS = {
"prepare_task": "准备采集", "prepare_task": "准备采集",
"open_product": "打开商品页", "open_product": "打开商品页",
"wait_ready": "等待商品页加载", "wait_ready": "等待商品页加载",
"read_product_status": "读取商品状态",
"read_title": "读取标题", "read_title": "读取标题",
"read_cover": "读取封面", "read_cover": "读取封面",
"download_cover": "下载封面", "download_cover": "下载封面",
@@ -718,11 +720,16 @@ class CollectTab(QWidget):
if not tasks: if not tasks:
self._set_status("没有可采集任务") self._set_status("没有可采集任务")
return return
collect_scope = self._choose_collect_scope()
if collect_scope is None:
self._set_status("已取消采集")
return
worker = CollectWorker( worker = CollectWorker(
tasks, tasks,
db_path=self.db_path, db_path=self.db_path,
config=self.config, config=self.config,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
collect_scope=collect_scope,
) )
activity_signal = getattr(worker, "activity", None) activity_signal = getattr(worker, "activity", None)
if activity_signal is not None: if activity_signal is not None:
@@ -742,6 +749,32 @@ class CollectTab(QWidget):
self._start_collect_activity() self._start_collect_activity()
thread.start() thread.start()
def _choose_collect_scope(self):
box = QMessageBox(self)
box.setIcon(QMessageBox.Question)
box.setWindowTitle("选择采集范围")
box.setText("请选择本轮要采集的商品范围。")
box.setInformativeText(
"程序会逐个打开商品详情页检测状态并保存结果。"
"采集所有状态商品包含未上架、审核中和状态未知商品,"
"可能增加采集时间,但本步骤不消耗 AI 点数。"
)
normal_button = box.addButton("只采集状态正常的商品", QMessageBox.AcceptRole)
normal_button.setObjectName("collectNormalOnlyButton")
all_button = box.addButton("采集所有状态的商品", QMessageBox.DestructiveRole)
all_button.setObjectName("collectAllStatusesButton")
all_button.setStyleSheet("color: #cf222e; font-weight: 600;")
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
cancel_button.setObjectName("collectScopeCancelButton")
box.setDefaultButton(normal_button)
box.setEscapeButton(cancel_button)
box.exec()
if box.clickedButton() is normal_button:
return product_status.COLLECT_SCOPE_NORMAL_ONLY
if box.clickedButton() is all_button:
return product_status.COLLECT_SCOPE_ALL
return None
def stop_collect(self, checked=False): def stop_collect(self, checked=False):
if self.collect_worker is not None: if self.collect_worker is not None:
self.collect_worker.cancel() self.collect_worker.cancel()
@@ -870,6 +903,17 @@ class CollectTab(QWidget):
skipped=payload.get("skipped", 0), skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0), failed=payload.get("failed", 0),
) )
status_counts = payload.get("product_status_counts") or {}
status_skipped = int(payload.get("status_scope_skipped", 0) or 0)
if status_counts:
message += ";状态正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}".format(
normal=status_counts.get("normal", 0),
unlisted=status_counts.get("unlisted", 0),
reviewing=status_counts.get("reviewing", 0),
unknown=status_counts.get("unknown", 0),
)
if status_skipped:
message += f";按范围略过{status_skipped}"
self._show_collect_account_summary(payload, message) self._show_collect_account_summary(payload, message)
if payload.get("collected", 0) > 0: if payload.get("collected", 0) > 0:
batch_id = self._active_batch_id() batch_id = self._active_batch_id()
@@ -917,14 +961,25 @@ class CollectTab(QWidget):
reused = payload.get("reused_accounts") or [] reused = payload.get("reused_accounts") or []
login_required = payload.get("login_required_accounts") or [] login_required = payload.get("login_required_accounts") or []
skipped = max(0, int(payload.get("skipped", 0) or 0)) skipped = max(0, int(payload.get("skipped", 0) or 0))
status_scope_skipped = int(payload.get("status_scope_skipped", 0) or 0)
account_skipped = max(0, skipped - status_scope_skipped)
skip_counts = normalize_skip_reason_counts( skip_counts = normalize_skip_reason_counts(
payload.get("skip_reason_counts"), payload.get("skip_reason_counts"),
skipped_total=skipped, skipped_total=account_skipped,
) )
if not launched and not reused and not login_required and skipped == 0: if (
not launched
and not reused
and not login_required
and account_skipped == 0
and status_scope_skipped == 0
):
return return
lines = [message] lines = [message]
skip_summary = format_skip_reason_summary(skip_counts, skipped_total=skipped) skip_summary = format_skip_reason_summary(
skip_counts,
skipped_total=account_skipped,
)
if skip_summary: if skip_summary:
lines.append(skip_summary) lines.append(skip_summary)
if skip_counts[ALIAS_UNMATCHED] > 0: if skip_counts[ALIAS_UNMATCHED] > 0:
@@ -941,6 +996,8 @@ class CollectTab(QWidget):
) )
if skip_counts[LOGIN_REQUIRED] > 0: if skip_counts[LOGIN_REQUIRED] > 0:
lines.append("请到账号管理完成对应账号登录后,再重新采集略过任务。") lines.append("请到账号管理完成对应账号登录后,再重新采集略过任务。")
if status_scope_skipped:
lines.append(f"本轮按范围略过{status_scope_skipped}个非正常状态商品,未下载标题和封面。")
if launched or reused or login_required: if launched or reused or login_required:
lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。") lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。")
text = "\n".join(lines) text = "\n".join(lines)
+56 -8
View File
@@ -20,6 +20,7 @@ from .. import (
image_studio_export, image_studio_export,
image_studio_generation, image_studio_generation,
image_studio_images, image_studio_images,
product_status,
) )
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
from .widgets import * from .widgets import *
@@ -1878,6 +1879,7 @@ class CollectWorker(BaseWorker):
config=None, config=None,
preflight=True, preflight=True,
diagnostic_log_dir=None, diagnostic_log_dir=None,
collect_scope="all",
): ):
super().__init__() super().__init__()
self.tasks = list(tasks) self.tasks = list(tasks)
@@ -1885,6 +1887,7 @@ class CollectWorker(BaseWorker):
self.config = config self.config = config
self.preflight = preflight self.preflight = preflight
self.diagnostic_log_dir = diagnostic_log_dir self.diagnostic_log_dir = diagnostic_log_dir
self.collect_scope = product_status.normalize_collect_scope(collect_scope)
self._run_id = None self._run_id = None
def execute(self): def execute(self):
@@ -1908,6 +1911,10 @@ class CollectWorker(BaseWorker):
login_required_accounts = {} login_required_accounts = {}
preflight_info = {} preflight_info = {}
skip_reason_counts = empty_skip_reason_counts() skip_reason_counts = empty_skip_reason_counts()
product_status_counts = {
status: 0 for status in product_status.VALID_PRODUCT_STATUSES
}
status_scope_skipped = 0
self._run_id = self._create_run_log(eligible, batch_ids) self._run_id = self._create_run_log(eligible, batch_ids)
self._emit_activity( self._emit_activity(
@@ -1935,6 +1942,9 @@ class CollectWorker(BaseWorker):
extra={ extra={
**blocked, **blocked,
"skip_reason_counts": dict(skip_reason_counts), "skip_reason_counts": dict(skip_reason_counts),
"collect_scope": self.collect_scope,
"product_status_counts": dict(product_status_counts),
"status_scope_skipped": status_scope_skipped,
}, },
) )
self._finish_run_log("blocked", summary) self._finish_run_log("blocked", summary)
@@ -2111,9 +2121,22 @@ class CollectWorker(BaseWorker):
{ {
"item_id": task.item_id, "item_id": task.item_id,
"old_cover_path": self._old_cover_path(account, task), "old_cover_path": self._old_cover_path(account, task),
"collection_scope": self.collect_scope,
}, },
on_step=on_step, on_step=on_step,
) )
detected_status = product_status.normalize_status(
result.get("product_status")
)
product_status_counts[detected_status] += 1
if result.get("product_status_error"):
self._write_diagnostic_log(
"商品状态检测失败,已按状态未知保存",
level="WARNING",
step="read_product_status",
task=task,
payload={"error": result.get("product_status_error")},
)
if result.get("close_target_confirmed") is False: if result.get("close_target_confirmed") is False:
self._log_run_event( self._log_run_event(
"step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;采集结果已保留,继续处理后续任务".format( "step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;采集结果已保留,继续处理后续任务".format(
@@ -2133,6 +2156,35 @@ class CollectWorker(BaseWorker):
"close_target_confirmed": False, "close_target_confirmed": False,
}, },
) )
if result.get("collection_skipped"):
activity_result = "skipped"
current_step = "read_product_status"
reason = result.get("collection_skip_reason") or product_status.collect_skip_reason(
detected_status
)
db.set_product_status(
task.id,
detected_status,
result.get("product_status_note"),
path=self.db_path,
)
db.mark_skipped(task.id, reason, path=self.db_path)
skipped += 1
status_scope_skipped += 1
self.row_updated.emit(
task.id,
{"status": "skipped", "last_error": reason},
)
self._log_run_event(
"step=read_product_status result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
continue
current_step = "db_write" current_step = "db_write"
self._emit_activity( self._emit_activity(
"task_step", "task_step",
@@ -2156,14 +2208,6 @@ class CollectWorker(BaseWorker):
product_status_note=result.get("product_status_note"), product_status_note=result.get("product_status_note"),
path=self.db_path, path=self.db_path,
) )
if result.get("product_status_error"):
self._write_diagnostic_log(
"商品状态检测失败,已按状态未知保存",
level="WARNING",
step="read_product_status",
task=task,
payload={"error": result.get("product_status_error")},
)
collected += 1 collected += 1
elapsed_ms = self._elapsed_ms(started) elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit( self.row_updated.emit(
@@ -2235,6 +2279,9 @@ class CollectWorker(BaseWorker):
**preflight_info, **preflight_info,
"login_required_accounts": list(login_required_accounts.values()), "login_required_accounts": list(login_required_accounts.values()),
"skip_reason_counts": dict(skip_reason_counts), "skip_reason_counts": dict(skip_reason_counts),
"collect_scope": self.collect_scope,
"product_status_counts": dict(product_status_counts),
"status_scope_skipped": status_scope_skipped,
}, },
) )
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
@@ -2540,6 +2587,7 @@ class CollectWorker(BaseWorker):
options={ options={
"batch_ids": batch_ids, "batch_ids": batch_ids,
"preflight": self.preflight, "preflight": self.preflight,
"collect_scope": self.collect_scope,
}, },
path=self.db_path, path=self.db_path,
) )
+21
View File
@@ -26,6 +26,14 @@ PRODUCT_STATUS_LABELS = {
STATUS_UNKNOWN: "状态未知", STATUS_UNKNOWN: "状态未知",
} }
SCOPE_NORMAL_ONLY = "normal_only"
SCOPE_ALL = "all"
# Kept as explicit collection aliases while generation and apply adopt the
# shared scope values in their own tasks.
COLLECT_SCOPE_NORMAL_ONLY = SCOPE_NORMAL_ONLY
COLLECT_SCOPE_ALL = SCOPE_ALL
_WHITESPACE_RE = re.compile(r"\s+") _WHITESPACE_RE = re.compile(r"\s+")
_NOTE_LIMIT = 2000 _NOTE_LIMIT = 2000
@@ -93,6 +101,19 @@ def partition_tasks(tasks) -> dict:
return grouped return grouped
def normalize_collect_scope(value) -> str:
value = str(value or "").strip().lower()
return SCOPE_ALL if value == SCOPE_ALL else SCOPE_NORMAL_ONLY
def should_collect_content(status, scope) -> bool:
return normalize_collect_scope(scope) == COLLECT_SCOPE_ALL or is_normal(status)
def collect_skip_reason(status) -> str:
return f"{status_label(status)},按本轮范围略过"
def _alert_note(title: str, description: str) -> str: def _alert_note(title: str, description: str) -> str:
parts = [] parts = []
if title: if title:
+2 -1
View File
@@ -342,7 +342,7 @@ CREATE TABLE run_log_events (
优先级: 优先级:
1. **T-207 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。失败时 DB 事件保存最后步骤和简短错误,本地 `data/logs/cmshopee.log` 保存完整脱敏 traceback。 1. **T-207/T-662b 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_product_status`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。运行日志选项记录本轮范围,汇总记录四类商品状态与按范围略过数。失败时 DB 事件保存最后步骤和简短错误,本地 `data/logs/cmshopee.log` 保存完整脱敏 traceback。
2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。 2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。
3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新蝦皮 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;账号管理中的「启动登录」创建 `run_type=chrome_launch`,账号管理中的「检测登录」创建 `run_type=login_check`;设置中的 AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。 3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新蝦皮 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;账号管理中的「启动登录」创建 `run_type=chrome_launch`,账号管理中的「检测登录」创建 `run_type=login_check`;设置中的 AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。
4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。 4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。
@@ -421,6 +421,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。 - 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。
- 若商品 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` 则四类状态均走现有采集流程。范围略过不是技术失败,汇总分别统计状态和略过数。
- 旧封面:取第一张 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 旧字段。
+4 -4
View File
@@ -269,7 +269,7 @@ read_product_status(cdp) -> dict # {product_status, product_status_
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 # 下载旧封面到本地
collect(account, task) -> dict # -> {old_title, old_cover_path, product_status, product_status_note, product_status_error, close_target_confirmed} collect(account, task) -> dict # task.collection_scope=normal_only/all;范围略过时返回 collection_skipped/reason,不读取标题/封面
# 应用 # 应用
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
@@ -292,7 +292,7 @@ apply_task(account, task, close_success_tab=False) -> dict
- `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` 快照。
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 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 秒。
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。 - T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
@@ -459,7 +459,7 @@ class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成
class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 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) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度 class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度
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
@@ -520,7 +520,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。 - 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
- 「采集旧标题/旧封面」通过 `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()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 - 「采集旧标题/旧封面」先弹范围确认框,默认安全策略 `normal_only`,红色危险选项为 `all`,取消不创建 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。
- 采集打开商品页时,若本轮自动新建 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` 枚举。
+1 -1
View File
@@ -72,7 +72,7 @@
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:点击后先为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 - 采集:点击后先选择范围,默认「只采集状态正常的商品」,红色危险选项为「采集所有状态的商品」。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。
- 若商品 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」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
+6 -2
View File
@@ -1,7 +1,7 @@
--- ---
id: T-662b id: T-662b
title: 导入采集范围确认与商品状态汇总 title: 导入采集范围确认与商品状态汇总
status: TODO status: DONE
phase: 7 phase: 7
deps: [T-662a] deps: [T-662a]
created: 2026-07-18 created: 2026-07-18
@@ -61,4 +61,8 @@ git diff --check
## 执行记录 ## 执行记录
- 待实现。 - 已实现采集范围确认框:默认“只采集状态正常的商品”,红色“采集所有状态的商品”,取消不启动 Worker。
- `CollectWorker` 将范围策略冻结在本轮运行日志中;逐条先读取并保存商品状态。默认范围仅采集正常商品,异常/未知商品保留旧标题和封面并按范围略过;全部范围继续采集四类状态。
- 汇总、行结果和运行日志分别统计四类状态与按范围略过数,账号预检略过统计不再把业务范围略过误报为“其他”。
- 已更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/api.md`。
- 验证通过:`py -3.10 -m unittest tests.test_editor_login tests.test_workers tests.test_gui`(267 项)、`py -3.10 -m unittest discover -s tests`(605 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
+27
View File
@@ -971,6 +971,33 @@ class EditorLoginTests(unittest.TestCase):
steps, steps,
) )
def test_collect_normal_only_skips_non_normal_before_reading_content(self):
cdp = FakeProductCDP(
"ws-status",
alerts=[{"title": "您的商品未上架", "description": "已下架"}],
)
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.collect(
{"debug_port": 9222},
{
"item_id": "51100639510",
"old_cover_path": "old.jpg",
"collection_scope": "normal_only",
},
)
self.assertTrue(result["collection_skipped"])
self.assertEqual("unlisted", result["product_status"])
self.assertEqual("未上架,按本轮范围略过", result["collection_skip_reason"])
read_title.assert_not_called()
read_cover_src.assert_not_called()
download_cover.assert_not_called()
def test_collect_keeps_reused_product_tab_open(self): def test_collect_keeps_reused_product_tab_open(self):
cdp = FakeProductCDP("ws-existing") cdp = FakeProductCDP("ws-existing")
cdp.target_id = "target-existing" cdp.target_id = "target-existing"
+47 -2
View File
@@ -28,7 +28,7 @@ from app import (
if gui.QT_IMPORT_ERROR is not None: if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装") raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, Qt from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, QTimer, Qt
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
QListView, QListView,
QPlainTextEdit, QPlainTextEdit,
QProgressBar, QProgressBar,
QPushButton,
QTableView, QTableView,
) )
@@ -7804,7 +7805,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
captured["started"] = True captured["started"] = True
captured = {} captured = {}
with mock.patch("app.gui.CollectWorker", FakeWorker), mock.patch( with mock.patch.object(
tab,
"_choose_collect_scope",
return_value="normal_only",
), mock.patch("app.gui.CollectWorker", FakeWorker), mock.patch(
"app.gui.run_worker", "app.gui.run_worker",
return_value=FakeThread(), return_value=FakeThread(),
): ):
@@ -7813,12 +7818,52 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertTrue(captured["started"]) self.assertTrue(captured["started"])
self.assertEqual(["1001"], [task.item_id for task in captured["tasks"]]) self.assertEqual(["1001"], [task.item_id for task in captured["tasks"]])
self.assertEqual(cfg["db_path"], captured["kwargs"]["db_path"]) self.assertEqual(cfg["db_path"], captured["kwargs"]["db_path"])
self.assertEqual("normal_only", captured["kwargs"]["collect_scope"])
self.assertFalse(tab.shop_filter.isEnabled()) self.assertFalse(tab.shop_filter.isEnabled())
self.assertFalse(tab.item_filter.isEnabled()) self.assertFalse(tab.item_filter.isEnabled())
self.assertFalse(tab.status_filter.isEnabled()) self.assertFalse(tab.status_filter.isEnabled())
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_collect_scope_dialog_uses_safe_default_and_red_all_status_choice(self):
with self.make_temp_dir() as temp_dir:
tab = CollectTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close)
captured = {}
def click_all_statuses():
box = QApplication.activeModalWidget()
normal_button = box.findChild(QPushButton, "collectNormalOnlyButton")
all_button = box.findChild(QPushButton, "collectAllStatusesButton")
captured["default"] = box.defaultButton().objectName()
captured["all_style"] = all_button.styleSheet()
self.assertIsNotNone(normal_button)
self.assertIsNotNone(all_button)
all_button.click()
QTimer.singleShot(0, click_all_statuses)
scope = tab._choose_collect_scope()
self.assertEqual("all", scope)
self.assertEqual("collectNormalOnlyButton", captured["default"])
self.assertIn("#cf222e", captured["all_style"])
self.assert_removed(temp_dir)
def test_collect_without_candidates_does_not_open_scope_dialog(self):
with self.make_temp_dir() as temp_dir:
statuses = []
tab = CollectTab(config=self.make_config(temp_dir), status_callback=statuses.append)
self.addCleanup(tab.close)
with mock.patch.object(tab, "_choose_collect_scope") as choose_scope:
tab.collect_old_data()
choose_scope.assert_not_called()
self.assertEqual("没有可采集任务", statuses[-1])
self.assert_removed(temp_dir)
def test_collect_activity_tracks_step_resets_each_task_and_freezes_on_stop(self): def test_collect_activity_tracks_step_resets_each_task_and_freezes_on_stop(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))
+161
View File
@@ -24,6 +24,7 @@ from app.gui.workers import (
ProductSuiteAiWriteWorker, ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker, ProductSuiteGenerateWorker,
ProductSuiteHistoryExportWorker, ProductSuiteHistoryExportWorker,
CollectWorker,
) )
@@ -119,6 +120,166 @@ class WorkerTests(unittest.TestCase):
self.assertEqual([(-1, "模拟失败")], failed) self.assertEqual([(-1, "模拟失败")], failed)
self.assertEqual([{"ok": False, "error": "模拟失败"}], finished) self.assertEqual([{"ok": False, "error": "模拟失败"}], finished)
def test_collect_worker_scope_skips_non_normal_without_overwriting_old_content(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(51100639510 + index),
}
for index in range(2, 6)
],
path=db_path,
)
tasks = db.list_tasks(batch_id=batch_id, path=db_path)
connection = db.connect(db_path)
try:
with connection:
connection.execute(
"UPDATE tasks SET old_title = ?, old_cover_path = ? WHERE id = ?",
("历史标题", "history.jpg", tasks[1].id),
)
finally:
connection.close()
status_by_item = {
tasks[0].item_id: "normal",
tasks[1].item_id: "unlisted",
tasks[2].item_id: "reviewing",
tasks[3].item_id: "unknown",
}
account = SimpleNamespace(alias="alias", account_name="店铺", debug_port=9222)
def fake_collect(_account, task, on_step=None):
status = status_by_item[task["item_id"]]
self.assertEqual("normal_only", task["collection_scope"])
on_step("read_product_status")
if status != "normal":
labels = {
"unlisted": "未上架",
"reviewing": "审核中",
"unknown": "状态未知",
}
return {
"product_status": status,
"product_status_note": f"{labels[status]}提示",
"collection_skipped": True,
"collection_skip_reason": f"{labels[status]},按本轮范围略过",
}
on_step("download_cover")
return {
"product_status": status,
"product_status_note": None,
"old_title": "新采集标题",
"old_cover_path": "new.jpg",
}
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.collect",
side_effect=fake_collect,
):
summary = CollectWorker(
tasks,
db_path=db_path,
preflight=False,
collect_scope="normal_only",
).execute()
self.assertEqual(1, summary["collected"])
self.assertEqual(3, summary["skipped"])
self.assertEqual(0, summary["failed"])
self.assertEqual(3, summary["status_scope_skipped"])
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)
by_status = {task.product_status: task for task in refreshed}
self.assertEqual("collected", by_status["normal"].stage)
self.assertEqual("success", by_status["normal"].status)
self.assertEqual("skipped", by_status["unlisted"].status)
self.assertEqual("imported", by_status["unlisted"].stage)
self.assertEqual("历史标题", by_status["unlisted"].old_title)
self.assertEqual("history.jpg", by_status["unlisted"].old_cover_path)
def test_collect_worker_all_scope_collects_every_detected_status(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(51100639600 + index),
}
for index in range(2, 6)
],
path=db_path,
)
tasks = db.list_tasks(batch_id=batch_id, path=db_path)
statuses = ["normal", "unlisted", "reviewing", "unknown"]
account = SimpleNamespace(alias="alias", account_name="店铺", debug_port=9222)
def fake_collect(_account, task, on_step=None):
self.assertEqual("all", task["collection_scope"])
on_step("read_product_status")
status = statuses.pop(0)
return {
"product_status": status,
"product_status_note": None,
"old_title": f"标题{status}",
"old_cover_path": f"{status}.jpg",
}
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.collect",
side_effect=fake_collect,
):
summary = CollectWorker(
tasks,
db_path=db_path,
preflight=False,
collect_scope="all",
).execute()
self.assertEqual(4, summary["collected"])
self.assertEqual(0, summary["skipped"])
self.assertEqual(0, summary["status_scope_skipped"])
self.assertEqual(
{"normal": 1, "unlisted": 1, "reviewing": 1, "unknown": 1},
summary["product_status_counts"],
)
self.assertTrue(
all(task.stage == "collected" for task in db.list_tasks(batch_id=batch_id, path=db_path))
)
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)