fix: capture invalid product toast and close failed tabs

This commit is contained in:
chengma
2026-07-02 21:46:32 +08:00
parent f3e0956cf6
commit b9f614b8e7
11 changed files with 378 additions and 28 deletions
+156 -2
View File
@@ -192,6 +192,36 @@ JS_TOASTS = (
"return JSON.stringify(t.slice(0,5));})()"
)
JS_TOAST_OBSERVER_SOURCE = (
"(function(){"
"if(window.__cmshopee_toast_observer_installed)return true;"
"window.__cmshopee_toast_observer_installed=true;"
"window.__cmshopee_toasts=window.__cmshopee_toasts||[];"
"function text(e){return ((e&&e.innerText)||(e&&e.textContent)||'').trim();}"
"function visible(e){if(!e)return false;var s=getComputedStyle(e);var r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;}"
"function rootOf(e){return e.closest?e.closest('.eds-toast,.eds-toasts,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')||e:e;}"
"function record(e){var root=rootOf(e);var value=text(root)||text(e);if(!value)return;window.__cmshopee_toasts.push({text:value,html:((root.outerHTML||e.outerHTML)||'').slice(0,2000),url:location.href,visible:visible(root)||visible(e),created_at:(new Date()).toISOString()});}"
"function scan(){try{Array.from(document.querySelectorAll('.eds-toasts .eds-toast,.eds-toast,.eds-toast__content,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')).forEach(record);window.__cmshopee_toasts=window.__cmshopee_toasts.slice(-20);}catch(e){}}"
"try{if(document.documentElement||document.body){var mo=new MutationObserver(function(){scan();});mo.observe(document.documentElement||document.body,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['style','class']});window.__cmshopee_toast_observer=mo;}}catch(e){}"
"scan();return true;"
"})()"
)
JS_INSTALL_TOAST_OBSERVER = JS_TOAST_OBSERVER_SOURCE
JS_PAGE_TOASTS = (
"(function(){"
"function text(e){return ((e&&e.innerText)||(e&&e.textContent)||'').trim();}"
"function visible(e){if(!e)return false;var s=getComputedStyle(e);var r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;}"
"function rootOf(e){return e.closest?e.closest('.eds-toast,.eds-toasts,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')||e:e;}"
"function record(e){var root=rootOf(e);var value=text(root)||text(e);if(!value)return null;return {text:value,html:((root.outerHTML||e.outerHTML)||'').slice(0,2000),url:location.href,visible:visible(root)||visible(e),created_at:(new Date()).toISOString()};}"
"var cached=(window.__cmshopee_toasts||[]).slice(-10);"
"var current=Array.from(document.querySelectorAll('.eds-toasts .eds-toast,.eds-toast,.eds-toast__content,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')).map(record).filter(Boolean);"
"var seen={};var out=[];cached.concat(current).forEach(function(item){var key=[item.text,item.html,item.url].join('|');if(seen[key])return;seen[key]=true;out.push(item);});"
"return JSON.stringify(out.slice(-10));"
"})()"
)
JS_POST_UPDATE_STATE = (
"(function(){"
"function visible(e){if(!e)return false;var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
@@ -314,11 +344,112 @@ def _is_login_url(url):
def _json_value(cdp, expr, default=None):
raw = cdp.val(expr)
if not raw:
if raw is None or raw == "":
return default
if isinstance(raw, (dict, list)):
return raw
return json.loads(raw)
PRODUCT_UNAVAILABLE_TEXT_MARKERS = (
"please input correct product id",
"商品失效",
"商品不存在",
"商品已删除",
"商品已下架",
"無效商品",
"无效商品",
"無效的商品",
"无效的商品",
"無權限",
"无权限",
"沒有權限",
"没有权限",
"無法查看商品",
"无法查看商品",
"product not found",
"item not found",
"not exist",
"not found",
"invalid product",
"invalid item",
)
def install_toast_observer(cdp) -> None:
try:
cdp.send("Page.addScriptToEvaluateOnNewDocument", {"source": JS_TOAST_OBSERVER_SOURCE})
except Exception:
pass
try:
cdp.val(JS_INSTALL_TOAST_OBSERVER)
except Exception:
pass
def read_page_toasts(cdp):
try:
values = _json_value(cdp, JS_PAGE_TOASTS, default=[])
except Exception:
return []
if not isinstance(values, list):
return []
toasts = []
for entry in values:
if isinstance(entry, str):
text = entry.strip()
if text:
toasts.append({"text": text, "html": "", "url": "", "visible": False, "created_at": ""})
continue
if not isinstance(entry, dict):
continue
text = str(entry.get("text") or "").strip()
if not text:
continue
toasts.append(
{
"text": text,
"html": str(entry.get("html") or "")[:2000],
"url": str(entry.get("url") or ""),
"visible": bool(entry.get("visible")),
"created_at": str(entry.get("created_at") or ""),
}
)
return toasts[-10:]
def is_product_unavailable_error(text):
value = str(text or "").strip().lower()
return bool(value) and any(marker.lower() in value for marker in PRODUCT_UNAVAILABLE_TEXT_MARKERS)
def product_unavailable_error_message(text):
value = str(text or "").strip()
if value.startswith("商品失效"):
return value
return f"商品失效:{value or '商品详情页无法加载'}"
def _product_unavailable_toast_text(toasts):
for toast in reversed(toasts or []):
text = str(toast.get("text") or "").strip()
if is_product_unavailable_error(text):
return text
return None
def _open_product_ready_error(cdp, fallback):
toasts = read_page_toasts(cdp)
invalid_text = _product_unavailable_toast_text(toasts)
if invalid_text:
return product_unavailable_error_message(invalid_text)
for toast in reversed(toasts):
text = str(toast.get("text") or "").strip()
if text:
return f"{fallback}:{text}"
return fallback
def _title_state(cdp):
return _json_value(cdp, JS_TITLE_STATE, default={}) or {}
@@ -506,8 +637,11 @@ def _wait_ready(cdp, timeout=60):
return True
except Exception:
pass
invalid_text = _product_unavailable_toast_text(read_page_toasts(cdp))
if invalid_text:
raise EditorError(product_unavailable_error_message(invalid_text))
time.sleep(1)
raise TimeoutError("等待 Shopee 商品编辑器就绪超时")
raise EditorError(_open_product_ready_error(cdp, "等待 Shopee 商品编辑器就绪超时"))
def _ensure_page_domains(cdp):
@@ -598,6 +732,20 @@ def is_logged_in(account) -> bool:
return bool(login_status(account).get("logged_in"))
def _close_open_product_failure(cdp):
target_id = getattr(cdp, "target_id", None)
created_by_app = bool(getattr(cdp, "created_by_app", False))
host = getattr(cdp, "cdp_host", None)
try:
cdp.close()
finally:
if created_by_app and target_id:
try:
close_tab(target_id, host=host)
except Exception:
pass
def open_product(account, item_id, on_step=None) -> CDP:
"""Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready."""
@@ -614,15 +762,21 @@ def open_product(account, item_id, on_step=None) -> CDP:
cdp.target_id = tab.get("id")
cdp.created_by_app = created_by_app
cdp.cdp_host = host
try:
_ensure_page_domains(cdp)
install_toast_observer(cdp)
try:
cdp.send("Page.bringToFront")
except Exception:
pass
cdp.send("Page.navigate", {"url": url})
install_toast_observer(cdp)
_notify_collect_step(on_step, "wait_ready")
_wait_ready(cdp)
return cdp
except Exception:
_close_open_product_failure(cdp)
raise
def read_title(cdp) -> str:
+28 -1
View File
@@ -21,6 +21,21 @@ class TaskTableModel(QAbstractTableModel):
"skipped": "略过",
"cancelled": "已取消",
}
PRODUCT_UNAVAILABLE_MARKERS = (
"商品失效",
"please input correct product id",
"商品不存在",
"商品已删除",
"商品已下架",
"無效商品",
"无效商品",
"無權限",
"无权限",
"product not found",
"item not found",
"invalid product",
"invalid item",
)
def __init__(self, parent=None):
super().__init__(parent)
@@ -72,8 +87,12 @@ class TaskTableModel(QAbstractTableModel):
return self._display_value(task, index.column())
if role == Qt.ForegroundRole and index.column() == 3:
return self._stage_color(task)
if role == Qt.ToolTipRole and self.is_unmatched(task):
if role == Qt.ToolTipRole:
if self.is_unmatched(task):
return "别名未匹配账号,采集时将略过"
error = getattr(task, "last_error", "") or ""
if error:
return str(error)
return None
def flags(self, index):
@@ -98,9 +117,17 @@ class TaskTableModel(QAbstractTableModel):
return account.account_name
return task.account_name or ""
def _is_product_unavailable(self, task) -> bool:
if getattr(task, "status", "") != "failed":
return False
error = str(getattr(task, "last_error", "") or "").lower()
return bool(error) and any(marker.lower() in error for marker in self.PRODUCT_UNAVAILABLE_MARKERS)
def _stage_text(self, task) -> str:
if self.is_unmatched(task):
return "略过"
if self._is_product_unavailable(task):
return "商品失效"
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
+2 -2
View File
@@ -64,14 +64,14 @@
3. 之后我选中该账号、填入商品 ID,工具自动打开该商品详情页(带登录态)。
4. 工具自动把标题改成目标值,并上传我指定的图片、把它拖到第一位设为封面。
5. 我在 ③ 按批次/店铺/状态筛选已生成任务,点击「开始更新」后看到批量确认弹窗;若本轮需要的账号 Chrome 未启动或未登录,工具先列出账号并中止;确认且账号均就绪后工具逐条提交线上,取消则不执行。
6. 当账号尚未登录、商品页加载失败或封面已满 9 张时,工具给出明确提示并安全处理。
6. 当账号尚未登录、商品页加载失败、商品 ID 已失效或封面已满 9 张时,工具给出明确提示并安全处理;若 Shopee 只弹出短暂错误 toast,工具应自动捕获并展示该错误文案。
## 五、验收标准(V1)
- **账号配置**:新增/编辑/删除账号后,配置持久化到本地文件,重启工具仍在。
- **绑定配置目录**:每个账号对应唯一的 `chrome_user_data_dir/<账号标识>` 目录;不同账号互不共用、不串号。
- **启动并登录**:点击后 Chrome 用对应 user-data-dir 启动并开启调试端口;人工登录一次后,再次启动无需重新登录。
- **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪。
- **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪;若商品 ID 失效、无权限或店铺不匹配导致详情页无法就绪,工具能显示 Shopee toast 错误文案并写入运行日志,而不是只报等待超时;若该失败发生在程序自动新建的商品 tab 内,失败后应自动关闭该 tab,复用用户原本打开的 tab 不关闭;①列表仅在明确捕获商品失效类 toast 时显示“商品失效”,其他加载失败仍显示“失败”。
- **改标题**:写入后标题框 `value` 与 `modelvalue` 均等于目标值(确认页面模型已更新)。
- **换封面**:上传成功后图片张数 +1(满 9 张时先确认旧封面本地备份存在,再删第一张并上传;备份缺失则拒绝删除);目标图被拖到第一位成为封面。
- **提交更新**:③ 点击「开始更新」后必须弹窗确认本次筛选范围和任务数量;真实提交前必须检查本轮需要的账号 Chrome 已启动且已登录,缺失则弹窗列出账号并中止本轮、不自动打开 Chrome;用户确认且账号就绪后才逐条点「更新」,按钮禁用则不点并记录失败原因。
+9 -1
View File
@@ -294,6 +294,8 @@ 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 事件保存最后步骤和简短错误,本地 `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 运行日志和本地 `logs/cmshopee.log` 都写脱敏错误。
3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新shopee 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `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。
原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。
关键事实:
@@ -362,6 +364,8 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
- 采集任务结束且本轮有成功采集行时,自动触发当前批次旧字段回写;原 Excel 被锁时不影响 SQLite 结果,提示关闭后重试,并保留手动「回写旧数据到 Excel」入口。
@@ -396,6 +400,8 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。标题-only 任务没有 `new_cover_path` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。
- `open_product` 打开商品详情页失败时,要把页面 toast 中的错误原因上浮到③运行日志和任务失败原因;商品 ID 失效、无权限或店铺不匹配时应能看到 Shopee 原始提示,而不是只看到等待详情页超时;如果此时 tab 是本轮自动新建的,`open_product` 要负责关闭该失败 tab。
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
- 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
- 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
@@ -403,7 +409,7 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
- 真实更新按每批最大条数分批执行,每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与检查都写运行日志,日志 payload 走脱敏工具。
- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。
- T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交,并保留 committed 历史事实/运行日志。
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;若确认后已跳回商品列表页,关闭前等待 2 秒;失败任务和复用的用户已有 tab 不关闭。
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;若确认后已跳回商品列表页,关闭前等待 2 秒;进入编辑页后的失败任务和复用的用户已有 tab 不关闭;`open_product` 内部打开失败的新建 tab 要关闭。
### 6.4 登录检测
@@ -419,6 +425,8 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭;确认成功跳回商品列表页时,关闭前等待 2 秒 |
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` |
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 |
+1
View File
@@ -77,6 +77,7 @@
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE |
| T-404a | ②/③ 选中记录重置(联调重试辅助) | T-303, T-403, T-504 | 现状:② AI生成和③ 更新shopee列表没有重置选中记录的功能,生成/上传/提交成功后难以用同一商品重复测试。方案:② 增加「重置生成结果」按钮,仅作用当前选中单条,确认后清空本地 `new_title/new_cover_path`、清空 `last_error`,把任务退回 `stage=collected/status=success`,默认不删除本地新封面文件,可在确认框提示路径;③ 增加「重置更新状态」按钮,仅作用当前选中单条,保留 `new_title/new_cover_path`,确认后把本地状态退回可更新(`stage=generated/status=pending`、清空 `last_error`),用于重复测试上传/提交。若记录 `committed=1`,确认框必须醒目提示“线上已提交过,本地重置不会回滚 Shopee,重复更新会再次提交线上”,并保留 committed 历史事实/运行日志;运行中禁用;两类重置都写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel,后续由手动回写同步本地状态 | DONE |
| T-404 | 真实 Shopee 单条更新冒烟验收 | T-403, T-501c | 已完成真实验收:2026-07-01 用户手动导入包含 5 个商品 ID 的 Excel,完成 AI 标题/图片生成,并通过 Tab③「开始更新」流程更新到 Shopee;覆盖真实编辑页、标题/封面更新、Shopee 站点侧确认框、提交后结果判断与本地状态链路。后续若 Shopee 页面结构变化,仍需按 CDP 规则补回归验证 | DONE |
| T-404b | 商品详情页加载失败 toast 自动捕获 | T-404, T-505 | 问题:③ 更新或①采集打开商品详情页时,如果商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出很快消失的错误 toast,例如 `please input correct product id`;用户来不及复制 HTML,GUI 也可能只报“商品详情页加载失败/等待超时”,无法判断真实原因。方案:在 `open_product()`/等待商品详情页就绪前安装或启用 toast 捕获,监听并兜底读取 `.eds-toasts .eds-toast__content`,保存最近 toast 的文本、`outerHTML`、当前 URL、时间和可见状态到页面内缓存;当标题输入框/图片管理器/上传入口等关键元素超时或页面不是商品编辑页时,优先把最近错误 toast 提升为失败原因,写入 `tasks.last_error`、`run_logs/run_log_events` 和本地 `logs/` 诊断日志,并可保存失败现场 HTML/toast JSON 片段用于排查。若 toast 明确属于商品失效/商品不存在/无权限类错误,则底层仍保持 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,①导入采集列表“阶段”列显示“商品失效”;其他打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部、尚未把 `cdp` 返回给上层,仍必须由 `open_product()` 自己清理:本轮自动新建的商品 tab 要断开 CDP 后关闭浏览器 target;复用用户原本打开的 tab 只断开 CDP,不关闭页面。不得记录 Cookie、密码、token;不绕过 Shopee 校验,不改变上传、删图、提交逻辑。同步 editor/CDP mock 和 GUI model 测试覆盖短暂 toast、隐藏 toast、商品 ID 失效错误文案上浮、自动新开 tab 在商品失效时关闭、复用 tab 不关闭、仅明确失效 toast 才显示“商品失效” | DONE |
## Phase 5 · 设置与收尾
+10 -5
View File
@@ -231,7 +231,9 @@ class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;supp
```python
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names}
is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
open_product(account, item_id) -> CDP # 连端口、导航商品页、等就绪;标记该 tab 是否本轮自动新建
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
open_product(account, item_id) -> CDP # 连端口、导航商品页、等就绪;标记该 tab 是否本轮自动新建;失败时上浮最近错误 toast,并清理本轮自动新建的失败 tab
# 采集(只读)
read_title(cdp) -> str
@@ -256,9 +258,10 @@ apply_task(account, task, close_success_tab=False) -> dict
- `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:自动新建 tab 断开 CDP 后关闭浏览器 target,复用用户已有 tab 只断开 CDP。
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。
- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次会关闭该自动新开 tab,关闭前等待 2 秒。
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭。
- ③ 更新流程默认不关闭已进入编辑页的商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,已进入编辑页后的失败任务和用户原本打开的 tab 保留现场;但 `open_product()` 内部打开失败的新建 tab 要关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次会关闭该自动新开 tab,关闭前等待 2 秒。
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败并保留现场。
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。
@@ -381,7 +384,8 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
- 别名未匹配 `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。
- 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
- 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
- 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
@@ -415,9 +419,10 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
- 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。
- 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。
- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;失败和复用的用户已有 tab 不关闭。
- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;进入编辑页后的失败和复用的用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
- T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚 Shopee,并保留 committed 历史事实/运行日志。
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -54,6 +54,7 @@
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `logs/`,用于定位失败卡在哪个步骤。
@@ -113,6 +114,7 @@
- 安全开关通过后,弹窗展示本次筛选条件、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
- 打开编辑页失败时,如果 Shopee 弹出错误 toast(如商品 ID 不正确、商品不存在、无权限),③运行日志和任务失败原因必须显示该 toast 文案;同时把 toast HTML/URL/时间写入本地诊断日志。用户不需要手动复制瞬时 toast 的 HTML。
- 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
- 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
@@ -120,7 +122,7 @@
- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;检查和真实更新都会写 `run_logs/run_log_events`。点击「检查本轮更新」或「开始更新」时先清空③界面可见日志并写入本轮检查/更新开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `logs/`,历史日志不自动混入当前运行界面。
- 分批更新时「停止」为协作式停止:已开始的当前商品跑到安全边界并写库后停止,不再开始新商品、不进入下一批;未开始任务保持原状态,下次可继续。
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动、CDP 端口不可访问或未登录时整体阻断并引导去④账号管理,不进入逐条提交,也不自动打开账号 Chrome。
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;失败任务和用户原本打开的 tab 不关闭。
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交;不得静默清除 committed 历史事实。
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
+27
View File
@@ -1046,3 +1046,30 @@
- 测试:更新 `tests/test_gui.py`,覆盖②/③进入页面不自动混入历史日志,以及②开始生成、③检查/真实更新启动时旧日志被清掉且本轮摘要出现。
- 文档:`docs/06-tasks.md` 将 T-523a 标为 DONE;同步 `docs/current-state.md`,下一个可领取任务更新为 T-524。
- 验证:`python -m unittest discover -s tests -p "test_gui.py"` 通过(72 tests)。
## 【2026-07-02】文档 · T-404b 商品详情页加载失败 toast 自动捕获
- 需求:商品 ID 失效、无权限或店铺不匹配时,Shopee 商品详情页可能只弹出很快消失的错误 toast,例如 `please input correct product id`;用户来不及复制 HTML,当前错误也可能只表现为商品详情页等待超时。
- 方案:新增 T-404b。`open_product()`/等待商品详情页就绪前安装或启用 `.eds-toasts .eds-toast__content` 捕获,保存 toast 文本、`outerHTML`、当前 URL、时间和可见状态;详情页关键元素超时或页面不符合商品编辑页时,把最近错误 toast 上浮到 `tasks.last_error`、`run_logs/run_log_events`、③/①可见日志和本地 `logs/` 诊断日志。可保存失败现场 HTML/toast JSON,禁止记录 Cookie、密码、token。
- 文档:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`;当前下一个可领取任务改为 T-404b。
- 验证:文档-only 更新,未运行单元测试。
## 【2026-07-02】文档补充 · T-404b 商品失效显示边界
- 需求补充:用户确认①导入采集列表可以在商品 ID 失效时显示更直观的“商品失效”,但必须确认不会影响②AI生成、③更新shopee、进度统计和 Excel 回写。
- 决策:只做 UI 显示映射,不新增或改写数据库 `stage` 枚举。捕获到明确商品失效/商品不存在/无权限类 toast 时,任务底层保持 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,①列表“阶段”列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他打开失败仍显示“失败”。
- 文档:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`。
- 验证:文档-only 更新,未运行单元测试。
## 【2026-07-02】T-404b 完成 · 商品详情页加载失败 toast 自动捕获
- 代码:`app/editor.py` 在 `open_product()` 导航商品详情页前后安装 toast 观察器,缓存最近 toast 的文本、HTML、URL、可见状态和时间;`_wait_ready()` 超时或检测到失效 toast 时,将商品失效/商品不存在/无权限类文案上浮为 `商品失效:<原始toast>`,其他打开失败仍保持普通失败原因。
- GUI:①导入采集任务表只在 `last_error` 明确包含商品失效类错误时把“阶段”列显示为“商品失效”;底层 DB 不新增 stage 枚举,仍保持 `stage=imported/status=failed`,其他超时、网络、登录、风控类错误仍显示“失败”。
- 测试:`tests/test_editor_login.py` 覆盖隐藏 toast 捕获、失效 toast 错误上浮和 open_product 安装观察器;`tests/test_gui.py` 覆盖只有明确失效错误才显示“商品失效”。
- 验证:已通过相关测试;全量验证见本轮最终结果。
## 【2026-07-02】文档补充 · T-404b 商品失效失败 tab 清理
- 问题:商品 ID 失效时,`_wait_ready()` 在 `open_product()` 内部抛出 `商品失效:...`,上层 `collect()` / `apply_task()` 尚未拿到 `cdp`,因此上层 finally 无法关闭本轮自动新建的商品 tab。
- 方案:补充 T-404b 边界。`open_product()` 如果在返回 `cdp` 前失败,要自行清理:本轮自动新建 tab 先断开 CDP 再调用 `close_tab(target_id, host=...)`;复用用户原本打开的 tab 只断开 CDP,不关闭页面。进入编辑页后的更新失败仍按原设计保留现场。
- 文档:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`。
- 验证:文档-only 更新,未运行单元测试。
## 【2026-07-02】T-404b 补丁完成 · 商品失效失败 tab 自动清理
- 代码:`app/editor.py` 新增 `open_product()` 内部失败清理。若 `_wait_ready()` 因商品失效 toast 或其他打开页异常在返回 `cdp` 前抛错,会先断开 CDP;本轮自动新建的商品 tab 继续调用 `close_tab(target_id, host=...)` 关闭,复用用户已有 tab 不关闭。
- 边界:只处理 `open_product()` 内部打开失败导致的残留 tab;进入编辑页后的更新失败仍按原设计保留现场,成功提交后的关闭仍受 ⑤ `close_success_tab` 控制。
- 测试:`tests/test_editor_login.py` 增加自动新建 tab 商品失效时关闭、复用 tab 商品失效时不关闭的覆盖。
- 验证:`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(36 tests)。
+97 -1
View File
@@ -39,10 +39,23 @@ class FakeCDP:
class FakeProductCDP:
def __init__(self, ws):
def __init__(self, ws, ready=True, toasts=None):
self.ws = ws
self.ready = ready
self.toasts = list(toasts or [])
self.closed = False
self.sent = []
self.toast_observer_installed = False
def val(self, expr):
if expr == editor.JS_READY:
return self.ready
if expr == editor.JS_INSTALL_TOAST_OBSERVER:
self.toast_observer_installed = True
return True
if expr == editor.JS_PAGE_TOASTS:
return json.dumps(self.toasts)
return None
def send(self, method, params=None):
self.sent.append((method, params or {}))
@@ -439,6 +452,89 @@ class EditorLoginTests(unittest.TestCase):
self.assertEqual("127.0.0.1:9223", cdp.cdp_host)
create_tab_info.assert_called_once()
def test_open_product_installs_toast_observer(self):
fake = FakeProductCDP("ws-new", ready=True)
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
), mock.patch("app.editor.CDP", return_value=fake), mock.patch(
"app.editor._ensure_page_domains"
), mock.patch("app.editor.time.sleep"):
cdp = editor.open_product({"debug_port": 9223}, "51100639511")
self.assertIs(fake, cdp)
self.assertTrue(fake.toast_observer_installed)
self.assertIn(
("Page.addScriptToEvaluateOnNewDocument", {"source": editor.JS_TOAST_OBSERVER_SOURCE}),
fake.sent,
)
def test_wait_ready_raises_product_unavailable_from_hidden_toast(self):
fake = FakeProductCDP(
"ws-new",
ready=False,
toasts=[
{
"text": "please input correct product id",
"html": "<div class='eds-toast__content'>please input correct product id</div>",
"url": "https://seller.shopee.tw/portal/product/invalid",
"visible": False,
"created_at": "2026-07-02T00:00:00Z",
}
],
)
with self.assertRaises(editor.EditorError) as ctx:
editor._wait_ready(fake, timeout=0)
self.assertIn("商品失效", str(ctx.exception))
self.assertIn("please input correct product id", str(ctx.exception))
toasts = editor.read_page_toasts(fake)
self.assertEqual("please input correct product id", toasts[0]["text"])
self.assertFalse(toasts[0]["visible"])
def test_open_product_closes_auto_created_tab_when_unavailable(self):
fake = FakeProductCDP(
"ws-new",
ready=False,
toasts=[{"text": "please input correct product id", "visible": False}],
)
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
), mock.patch("app.editor.CDP", return_value=fake), mock.patch(
"app.editor._ensure_page_domains"
), mock.patch("app.editor.close_tab", return_value=True) as close_tab:
with self.assertRaises(editor.EditorError) as ctx:
editor.open_product({"debug_port": 9223}, "bad-item")
self.assertIn("商品失效", str(ctx.exception))
self.assertTrue(fake.closed)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9223")
def test_open_product_keeps_reused_tab_when_unavailable(self):
fake = FakeProductCDP(
"ws-existing",
ready=False,
toasts=[{"text": "please input correct product id", "visible": False}],
)
with mock.patch(
"app.editor.find_product_tab",
return_value={"id": "target-existing", "webSocketDebuggerUrl": "ws-existing"},
), mock.patch("app.editor.create_tab_info") as create_tab_info, mock.patch(
"app.editor.CDP",
return_value=fake,
), mock.patch("app.editor._ensure_page_domains"), mock.patch(
"app.editor.close_tab",
return_value=True,
) as close_tab:
with self.assertRaises(editor.EditorError):
editor.open_product({"debug_port": 9223}, "bad-item")
self.assertTrue(fake.closed)
close_tab.assert_not_called()
create_tab_info.assert_not_called()
def test_collect_closes_only_auto_created_product_tab(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
+29
View File
@@ -3,6 +3,7 @@ import unittest
import os
import sys
import threading
from types import SimpleNamespace
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@@ -82,6 +83,34 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsNotNone(value)
self.assertEqual(color, value.name())
def test_collect_table_shows_product_unavailable_only_for_explicit_error(self):
account = SimpleNamespace(alias="papa", account_name="papa 店铺")
invalid_task = SimpleNamespace(
alias="papa",
account_name="papa 店铺",
item_id="25120403046",
stage="imported",
status="failed",
last_error="商品失效:please input correct product id",
)
generic_task = SimpleNamespace(
alias="papa",
account_name="papa 店铺",
item_id="26887160467",
stage="imported",
status="failed",
last_error="等待 Shopee 商品编辑器就绪超时",
)
model = gui.TaskTableModel()
model.set_tasks([invalid_task, generic_task], [account])
self.assertEqual("商品失效", model.data(model.index(0, 3), gui.Qt.DisplayRole))
self.assertEqual("失败", model.data(model.index(1, 3), gui.Qt.DisplayRole))
self.assertIn(
"please input correct product id",
model.data(model.index(0, 3), gui.Qt.ToolTipRole),
)
def test_main_window_has_five_tabs_in_workflow_order(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))