fix: capture invalid product toast and close failed tabs
This commit is contained in:
+156
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;用户确认且账号就绪后才逐条点「更新」,按钮禁用则不点并记录失败原因。
|
||||
|
||||
@@ -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),下载到本地 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user