feat: 完成T-502满9张封面替换
实现 replace_cover 满9张时先校验 old_cover_path 本地备份,备份缺失返回 OLD_COVER_BACKUP_MISSING 且不删除线上图片。 备份存在时点击第一张删除按钮并处理可见确认框,删除后继续上传新封面并拖到第一位;apply_task 会把任务 old_cover_path 传入 replace_cover。 补充 editor 单测覆盖备份缺失阻断、删除确认后上传拖首位、删除按钮缺失和 old_cover_path 传递;同步需求、架构、编码规则、任务看板、API、routes、current-state 与 progress。 已在测试商品 29671243750 上验证 9图删除和满9张替换的不提交流程;未点击更新保存线上。
This commit is contained in:
+140
-9
@@ -84,6 +84,46 @@ JS_TOASTS = (
|
|||||||
"return JSON.stringify(t.slice(0,5));})()"
|
"return JSON.stringify(t.slice(0,5));})()"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
JS_CLICK_FIRST_DELETE = (
|
||||||
|
"(function(){"
|
||||||
|
f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,"
|
||||||
|
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);"
|
||||||
|
"if(!s.snapshotLength)return JSON.stringify({clicked:false,reason:'NO_IMAGE'});"
|
||||||
|
"var item=s.snapshotItem(0);"
|
||||||
|
"['mouseover','mouseenter','mousemove'].forEach(function(n){"
|
||||||
|
"item.dispatchEvent(new MouseEvent(n,{bubbles:true,view:window}));});"
|
||||||
|
"var selectors=['.shopee-image-manager__icon--delete','[class*=\"icon--delete\"]','[class*=\"delete\"]'];"
|
||||||
|
"var btn=null;"
|
||||||
|
"for(var i=0;i<selectors.length&&!btn;i++){btn=item.querySelector(selectors[i]);}"
|
||||||
|
"if(!btn){var all=[].slice.call(item.querySelectorAll('*'));"
|
||||||
|
"btn=all.find(function(e){var t=[e.className,e.innerText,e.title,e.getAttribute('aria-label')].join(' ');"
|
||||||
|
"return /(delete|删除|刪除)/i.test(t);});}"
|
||||||
|
"if(!btn)return JSON.stringify({clicked:false,reason:'NO_DELETE_BUTTON'});"
|
||||||
|
"if(btn.scrollIntoView)btn.scrollIntoView({block:'center'});"
|
||||||
|
"btn.click();return JSON.stringify({clicked:true,reason:null});})()"
|
||||||
|
)
|
||||||
|
|
||||||
|
JS_CLICK_DELETE_CONFIRM = (
|
||||||
|
"(function(){"
|
||||||
|
"function visible(e){var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
|
||||||
|
"return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
|
||||||
|
"var rootSelectors='[role=dialog],[class*=dialog],[class*=Dialog],[class*=modal],"
|
||||||
|
"[class*=Modal],[class*=popover],[class*=Popover],[class*=popup],[class*=Popup]';"
|
||||||
|
"var roots=[].slice.call(document.querySelectorAll(rootSelectors)).filter(visible);"
|
||||||
|
"var yes=/(删除|刪除|確認|确认|確定|确定|OK|Yes)/i;"
|
||||||
|
"var no=/(取消|cancel|否|No)/i;"
|
||||||
|
"var seen=[];var candidates=[];"
|
||||||
|
"roots.forEach(function(root){[].slice.call(root.querySelectorAll('button,[role=button]')).forEach(function(b){"
|
||||||
|
"var text=((b.innerText||b.textContent||b.getAttribute('aria-label')||'')+'').trim();"
|
||||||
|
"if(!text||!visible(b))return;seen.push(text);"
|
||||||
|
"if(b.disabled||/disabled/i.test(b.className)||no.test(text)||!yes.test(text))return;"
|
||||||
|
"candidates.push({button:b,text:text,score:/(删除|刪除)/i.test(text)?0:1});});});"
|
||||||
|
"if(!candidates.length)return JSON.stringify({clicked:false,reason:'NO_CONFIRM_BUTTON',buttons:seen.slice(0,8)});"
|
||||||
|
"candidates.sort(function(a,b){return a.score-b.score;});"
|
||||||
|
"candidates[0].button.click();"
|
||||||
|
"return JSON.stringify({clicked:true,reason:null,text:candidates[0].text});})()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EditorError(RuntimeError):
|
class EditorError(RuntimeError):
|
||||||
"""Raised for expected editor automation failures with user-readable messages."""
|
"""Raised for expected editor automation failures with user-readable messages."""
|
||||||
@@ -383,11 +423,11 @@ def change_title(cdp, new_title) -> dict:
|
|||||||
return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue}
|
return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue}
|
||||||
|
|
||||||
|
|
||||||
def replace_cover(cdp, image_win_path, timeout=90) -> dict:
|
def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict:
|
||||||
"""Upload an image and drag it to the first position.
|
"""Upload an image and drag it to the first position.
|
||||||
|
|
||||||
Full 9-image deletion is intentionally left for T-502 because its confirm dialog
|
When the image manager is full, only delete the current first image if the
|
||||||
selector is not verified yet.
|
old cover backup from the collect stage exists locally.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
image_win_path = os.path.abspath(str(image_win_path))
|
image_win_path = os.path.abspath(str(image_win_path))
|
||||||
@@ -400,12 +440,30 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict:
|
|||||||
)
|
)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
before = _image_rects(cdp)
|
before = _image_rects(cdp)
|
||||||
|
count_before = len(before)
|
||||||
|
delete_result = None
|
||||||
if len(before) >= 9:
|
if len(before) >= 9:
|
||||||
return {"ok": False, "reason": "FULL_IMAGE_SLOTS", "count_before": len(before)}
|
backup_path = _validated_old_cover_backup(old_cover_path)
|
||||||
|
if not backup_path:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "OLD_COVER_BACKUP_MISSING",
|
||||||
|
"count_before": count_before,
|
||||||
|
"old_cover_path": old_cover_path,
|
||||||
|
}
|
||||||
|
delete_result = _delete_first_cover(cdp, before)
|
||||||
|
if not delete_result.get("ok"):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": delete_result.get("reason"),
|
||||||
|
"count_before": count_before,
|
||||||
|
"delete": delete_result,
|
||||||
|
}
|
||||||
|
before = _image_rects(cdp)
|
||||||
before_srcs = {r.get("src") for r in before}
|
before_srcs = {r.get("src") for r in before}
|
||||||
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
|
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
|
||||||
if not oid:
|
if not oid:
|
||||||
return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": len(before)}
|
return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": count_before, "delete": delete_result}
|
||||||
|
|
||||||
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]})
|
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]})
|
||||||
cdp.val(
|
cdp.val(
|
||||||
@@ -430,13 +488,13 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict:
|
|||||||
new_src = ready[-1]["src"]
|
new_src = ready[-1]["src"]
|
||||||
break
|
break
|
||||||
if not new_src:
|
if not new_src:
|
||||||
return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": len(before)}
|
return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": count_before, "delete": delete_result}
|
||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
cur = _image_rects(cdp)
|
cur = _image_rects(cdp)
|
||||||
new_rect = next((r for r in cur if r.get("src") == new_src), None)
|
new_rect = next((r for r in cur if r.get("src") == new_src), None)
|
||||||
if not new_rect:
|
if not new_rect:
|
||||||
return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src}
|
return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src, "delete": delete_result}
|
||||||
first = cur[0]
|
first = cur[0]
|
||||||
cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"])
|
cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"])
|
||||||
time.sleep(1.2)
|
time.sleep(1.2)
|
||||||
@@ -449,8 +507,77 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict:
|
|||||||
"reason": None if cover_ok else "DRAG_NOT_FIRST",
|
"reason": None if cover_ok else "DRAG_NOT_FIRST",
|
||||||
"new_src": new_src,
|
"new_src": new_src,
|
||||||
"index": index,
|
"index": index,
|
||||||
"count_before": len(before),
|
"count_before": count_before,
|
||||||
"count_after": len(after),
|
"count_after": len(after),
|
||||||
|
"delete": delete_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_old_cover_backup(old_cover_path):
|
||||||
|
if not old_cover_path:
|
||||||
|
return None
|
||||||
|
path = os.path.abspath(str(old_cover_path))
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_first_cover(cdp, before, timeout=15):
|
||||||
|
count_before = len(before)
|
||||||
|
if not before:
|
||||||
|
return {"ok": False, "reason": "NO_IMAGE", "count_before": 0}
|
||||||
|
first = before[0]
|
||||||
|
try:
|
||||||
|
cdp.send(
|
||||||
|
"Input.dispatchMouseEvent",
|
||||||
|
{"type": "mouseMoved", "x": first["x"], "y": first["y"]},
|
||||||
|
)
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
click_result = _json_value(
|
||||||
|
cdp,
|
||||||
|
JS_CLICK_FIRST_DELETE,
|
||||||
|
default={"clicked": False, "reason": "NO_DELETE_BUTTON"},
|
||||||
|
) or {}
|
||||||
|
if not click_result.get("clicked"):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": click_result.get("reason") or "NO_DELETE_BUTTON",
|
||||||
|
"count_before": count_before,
|
||||||
|
"click": click_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm_result = None
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
cur = _image_rects(cdp)
|
||||||
|
if len(cur) < count_before:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"reason": None,
|
||||||
|
"count_before": count_before,
|
||||||
|
"count_after": len(cur),
|
||||||
|
"click": click_result,
|
||||||
|
"confirm": confirm_result,
|
||||||
|
}
|
||||||
|
confirm_attempt = _json_value(
|
||||||
|
cdp,
|
||||||
|
JS_CLICK_DELETE_CONFIRM,
|
||||||
|
default={"clicked": False, "reason": "NO_CONFIRM_BUTTON"},
|
||||||
|
) or {}
|
||||||
|
if confirm_attempt.get("clicked"):
|
||||||
|
confirm_result = confirm_attempt
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
cur = _image_rects(cdp)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "DELETE_TIMEOUT",
|
||||||
|
"count_before": count_before,
|
||||||
|
"count_after": len(cur),
|
||||||
|
"click": click_result,
|
||||||
|
"confirm": confirm_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -491,7 +618,11 @@ def apply_task(account, task, close_success_tab=False) -> dict:
|
|||||||
if not title_result.get("ok"):
|
if not title_result.get("ok"):
|
||||||
return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result}
|
return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result}
|
||||||
if new_cover_path:
|
if new_cover_path:
|
||||||
cover_result = replace_cover(cdp, new_cover_path)
|
cover_result = replace_cover(
|
||||||
|
cdp,
|
||||||
|
new_cover_path,
|
||||||
|
old_cover_path=_get(task, "old_cover_path"),
|
||||||
|
)
|
||||||
if not cover_result.get("ok"):
|
if not cover_result.get("ok"):
|
||||||
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result}
|
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result}
|
||||||
update_result = click_update(cdp)
|
update_result = click_update(cdp)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| 生成快捷方式 | 可选:为每账号生成桌面快捷方式,目标带该账号 user-data-dir,供手动打开对应账号 Chrome | P1 |
|
| 生成快捷方式 | 可选:为每账号生成桌面快捷方式,目标带该账号 user-data-dir,供手动打开对应账号 Chrome | P1 |
|
||||||
| 加载商品页 | 选定账号,用其已登录 Chrome 打开指定商品详情页 | P0 |
|
| 加载商品页 | 选定账号,用其已登录 Chrome 打开指定商品详情页 | P0 |
|
||||||
| 改标题 | 对该商品按规则修改标题(如去掉结尾若干字 / 指定新标题) | P0 |
|
| 改标题 | 对该商品按规则修改标题(如去掉结尾若干字 / 指定新标题) | P0 |
|
||||||
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时先删原封面再传) | P0 |
|
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 |
|
||||||
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
|
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
|
||||||
|
|
||||||
### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线)
|
### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线)
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
- **启动并登录**:点击后 Chrome 用对应 user-data-dir 启动并开启调试端口;人工登录一次后,再次启动无需重新登录。
|
- **启动并登录**:点击后 Chrome 用对应 user-data-dir 启动并开启调试端口;人工登录一次后,再次启动无需重新登录。
|
||||||
- **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪。
|
- **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪。
|
||||||
- **改标题**:写入后标题框 `value` 与 `modelvalue` 均等于目标值(确认页面模型已更新)。
|
- **改标题**:写入后标题框 `value` 与 `modelvalue` 均等于目标值(确认页面模型已更新)。
|
||||||
- **换封面**:上传成功后图片张数 +1(满 9 张时先删第一张再传);目标图被拖到第一位成为封面。
|
- **换封面**:上传成功后图片张数 +1(满 9 张时先确认旧封面本地备份存在,再删第一张并上传;备份缺失则拒绝删除);目标图被拖到第一位成为封面。
|
||||||
- **提交更新**:③ 点击「开始更新」后必须弹窗确认本次筛选范围和任务数量;用户确认后才逐条点「更新」,按钮禁用则不点并记录失败原因。
|
- **提交更新**:③ 点击「开始更新」后必须弹窗确认本次筛选范围和任务数量;用户确认后才逐条点「更新」,按钮禁用则不点并记录失败原因。
|
||||||
- **安全**:任意失败路径不崩溃、不误删、不在缺少批量确认的情况下提交线上。
|
- **安全**:任意失败路径不崩溃、不误删、不在缺少批量确认的情况下提交线上。
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
|
|||||||
- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。
|
- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。
|
||||||
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
|
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
|
||||||
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。
|
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。
|
||||||
|
- 满 9 张封面时,T-502 的删除流程必须先确认该任务已有本地旧封面备份:`old_cover_path` 非空且文件存在。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
|
||||||
- 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。
|
- 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。
|
||||||
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。
|
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。
|
||||||
|
|
||||||
@@ -357,11 +358,11 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
|
|||||||
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 |
|
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 |
|
||||||
| 上传成功 | 张数 +1 且新图 src 为 `susercontent` |
|
| 上传成功 | 张数 +1 且新图 src 为 `susercontent` |
|
||||||
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
|
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
|
||||||
| 满 9 张 | 上限 9;换封面先删第一张(`.shopee-image-manager__icon--delete`,确认框待实测)|
|
| 满 9 张 | 上限 9;T-502 换封面时先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮,随后上传新图并拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程 |
|
||||||
| 更新按钮 | `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败 |
|
| 更新按钮 | `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败 |
|
||||||
| 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 |
|
| 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 |
|
||||||
|
|
||||||
高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
|
高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。删满 9 张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
|
||||||
|
|
||||||
## 八、推荐开发顺序
|
## 八、推荐开发顺序
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
- [ ] `python -m compileall app main.py` 通过(T-000 前仅文档改动不要求)。
|
- [ ] `python -m compileall app main.py` 通过(T-000 前仅文档改动不要求)。
|
||||||
- [ ] T-006 完成后,纯逻辑改动有对应 `unittest`,至少覆盖正常路径和一个失败路径。
|
- [ ] T-006 完成后,纯逻辑改动有对应 `unittest`,至少覆盖正常路径和一个失败路径。
|
||||||
- [ ] 涉及 CDP 的改动,在测试商品(ITEM_ID 51100639510)上实跑验证。
|
- [ ] 涉及 CDP 的改动,在测试商品(ITEM_ID 51100639510)上实跑验证。
|
||||||
|
- [ ] 涉及满 9 张封面删除时,必须先验证该任务 `old_cover_path` 本地备份存在;备份缺失不得删除线上图片。
|
||||||
- [ ] 涉及 DB 的改动,覆盖 schema 初始化、重复初始化、短事务写入、失败状态写入。
|
- [ ] 涉及 DB 的改动,覆盖 schema 初始化、重复初始化、短事务写入、失败状态写入。
|
||||||
- [ ] 涉及 Excel 的改动,覆盖 source_file_abs/source_sheet/source_row 回写定位。
|
- [ ] 涉及 Excel 的改动,覆盖 source_file_abs/source_sheet/source_row 回写定位。
|
||||||
- [ ] 对得上需求验收标准(如标题 `value`+`modelvalue` 双等于、封面在第一位)。
|
- [ ] 对得上需求验收标准(如标题 `value`+`modelvalue` 双等于、封面在第一位)。
|
||||||
@@ -87,7 +88,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交)
|
|||||||
|
|
||||||
- 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。
|
- 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。
|
||||||
- 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。
|
- 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。
|
||||||
- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证。
|
- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。
|
||||||
- V1 无常驻提交开关;③ 的批量确认弹窗是提交线上前的确认边界。dry-run 属 V2。
|
- V1 无常驻提交开关;③ 的批量确认弹窗是提交线上前的确认边界。dry-run 属 V2。
|
||||||
|
|
||||||
## 9. 拿不准就问
|
## 9. 拿不准就问
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@
|
|||||||
| T-501 | Tab⑤ 设置 · AI 模型管理 UI(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-005, T-104 | 复用 T-005 后端;name 唯一;类别 text/image;至少各一个;密钥本地明文保存、UI 打码;测试连接可用 | DONE |
|
| T-501 | Tab⑤ 设置 · AI 模型管理 UI(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-005, T-104 | 复用 T-005 后端;name 唯一;类别 text/image;至少各一个;密钥本地明文保存、UI 打码;测试连接可用 | DONE |
|
||||||
| T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | DONE |
|
| T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | DONE |
|
||||||
| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE |
|
| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE |
|
||||||
| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 实测删除确认框并处理;满 9 张流程正确 | TODO |
|
| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;删除前必须确认该任务已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失则拒绝删除并报错;已在 9 图测试商品 `29671243750` 上实测不提交流程 | DONE |
|
||||||
| T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | TODO |
|
| T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | TODO |
|
||||||
| T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO |
|
| T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO |
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -215,7 +215,9 @@ collect(account, task) -> dict # -> {old_title, old_cover_path}
|
|||||||
|
|
||||||
# 应用
|
# 应用
|
||||||
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
||||||
replace_cover(cdp, image_win_path) -> dict # 上传→等CDN→拖第一位;满9张暂返回 FULL_IMAGE_SLOTS,删除流程留给 T-502
|
replace_cover(cdp, image_win_path, old_cover_path=None) -> dict
|
||||||
|
# 上传→等CDN→拖第一位;满9张时先确认 old_cover_path 非空且文件存在,
|
||||||
|
# 再点第一张删除和可见确认框;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片
|
||||||
click_update(cdp) -> dict # {clicked, reason};禁用则记失败
|
click_update(cdp) -> dict # {clicked, reason};禁用则记失败
|
||||||
apply_task(account, task, close_success_tab=False) -> dict
|
apply_task(account, task, close_success_tab=False) -> dict
|
||||||
# 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已通过安全开关和批量确认)
|
# 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已通过安全开关和批量确认)
|
||||||
@@ -231,6 +233,7 @@ apply_task(account, task, close_success_tab=False) -> dict
|
|||||||
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
||||||
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。
|
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。
|
||||||
- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。
|
- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。
|
||||||
|
- T-502 满 9 张封面删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。
|
||||||
|
|
||||||
## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP)
|
## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP)
|
||||||
|
|
||||||
@@ -448,5 +451,5 @@ set UPDATE=1 && python prototypes/demo.py # 走完点击「更新」提交
|
|||||||
## 待实现时确认
|
## 待实现时确认
|
||||||
|
|
||||||
- AI 服务商/模型/计费;图像 image-to-image 能力与合规。
|
- AI 服务商/模型/计费;图像 image-to-image 能力与合规。
|
||||||
- 满 9 张删除封面的确认框选择器(需实测)。
|
- 满 9 张删除封面已在测试商品 `29671243750` 上实测不提交流程;当前代码只在可见 dialog/modal/popover 内点击删除/确认类按钮,并保留“旧封面备份缺失则拒绝删除”的保护。
|
||||||
- 旧封面下载的图片格式/扩展名处理。
|
- 旧封面下载的图片格式/扩展名处理。
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-06-29
|
- 日期:2026-06-29
|
||||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关。
|
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 满 9 张封面删除再上传。
|
||||||
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。
|
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。
|
||||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取与 Shopee 更新安全默认值,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
|
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、上传新图并拖到第一位的代码路径;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取与 Shopee 更新安全默认值,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
|
||||||
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。
|
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/上传拖首位 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。
|
||||||
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
|
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
|
||||||
|
|
||||||
## 既定设计要点(文档已定)
|
## 既定设计要点(文档已定)
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全设置 |
|
| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全设置 |
|
||||||
| `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
|
| `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
|
||||||
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 |
|
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 |
|
||||||
| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 |
|
| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c/T-502 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、满 9 张先校验旧封面备份再删第一张并上传、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 |
|
||||||
| `app/appconfig.py` | 已有 | T-002/T-501c 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全默认值;拒绝敏感字段写入 |
|
| `app/appconfig.py` | 已有 | T-002/T-501c 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全默认值;拒绝敏感字段写入 |
|
||||||
| `app/ai.py` | 已有 | T-301/T-303 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、停止取消未开始项 |
|
| `app/ai.py` | 已有 | T-301/T-303 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、停止取消未开始项 |
|
||||||
| `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 |
|
| `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 |
|
||||||
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 |
|
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 |
|
||||||
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
|
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
|
||||||
| `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 |
|
| `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 |
|
||||||
| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers |
|
| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers |
|
||||||
| `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 |
|
| `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 |
|
||||||
| `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 |
|
| `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 |
|
||||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
|
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
|
||||||
@@ -60,8 +60,7 @@
|
|||||||
|
|
||||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
|
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
|
||||||
|
|
||||||
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)。
|
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)。
|
||||||
- 正在进行:无。
|
|
||||||
- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。
|
- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。
|
||||||
|
|
||||||
## 当前已知限制
|
## 当前已知限制
|
||||||
@@ -70,6 +69,7 @@
|
|||||||
- T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。
|
- T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。
|
||||||
- T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。
|
- T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。
|
||||||
- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。
|
- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。
|
||||||
|
- T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断。
|
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断。
|
||||||
- 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
- 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
||||||
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。
|
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。
|
||||||
|
- 满 9 张封面时,删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
|
||||||
- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。
|
- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。
|
||||||
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。
|
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。
|
||||||
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。
|
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。
|
||||||
|
|||||||
+28
@@ -605,3 +605,31 @@
|
|||||||
- 测试:`tests/test_gui.py` 覆盖安全设置默认值、保存到 `config.json`、⑤保存后③立即读取共享安全配置、③安全拦截、最大条数/测试商品 ID 校验、确认后 worker 接收 `close_success_tab`;`tests/test_editor_login.py` 覆盖成功提交后关闭本轮新开 tab、失败时不关闭。
|
- 测试:`tests/test_gui.py` 覆盖安全设置默认值、保存到 `config.json`、⑤保存后③立即读取共享安全配置、③安全拦截、最大条数/测试商品 ID 校验、确认后 worker 接收 `close_success_tab`;`tests/test_editor_login.py` 覆盖成功提交后关闭本轮新开 tab、失败时不关闭。
|
||||||
- 文档:`docs/06-tasks.md` 将 T-501c 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-404。
|
- 文档:`docs/06-tasks.md` 将 T-501c 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-404。
|
||||||
- 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(42 tests);`python -m unittest discover -s tests -p test_editor_login.py` 通过(11 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(100 tests)。未执行真实 Shopee 提交,T-404 仍需在测试商品上手动冒烟。
|
- 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(42 tests);`python -m unittest discover -s tests -p test_editor_login.py` 通过(11 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(100 tests)。未执行真实 Shopee 提交,T-404 仍需在测试商品上手动冒烟。
|
||||||
|
|
||||||
|
## 【2026-06-29】T-502 满 9 张封面删除前备份策略
|
||||||
|
|
||||||
|
- 状态:文档决策更新,代码未实现。
|
||||||
|
- 决策:满 9 张封面需要删除第一张再上传时,不允许盲删线上图片;删除前必须确认该任务已有本地旧封面备份,即 `old_cover_path` 非空且文件存在。
|
||||||
|
- 失败语义:备份缺失时直接阻断该条封面更新,返回明确错误,要求先回到①采集旧封面或修复本地备份;不进入删除确认框、不删除线上第一张图。
|
||||||
|
- 实测边界:删除确认框选择器仍需在真实 Shopee 测试商品页面探查;真实保存线上变更仍需经过 ⑤ 安全开关和 ③ 二次确认。
|
||||||
|
- 变更:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/05-coding-rules.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`。
|
||||||
|
- 验证:仅文档更新,未运行测试。
|
||||||
|
|
||||||
|
## 【2026-06-29】T-502 满 9 张封面删除代码路径
|
||||||
|
|
||||||
|
- 状态:BLOCKED(代码路径和 mock 单测已完成;真实 Shopee 9 图商品删除确认框仍需实测)。
|
||||||
|
- 变更:`app/editor.py` 的 `replace_cover()` 支持 `old_cover_path`,满 9 张时先校验本地旧封面备份存在;缺失时返回 `OLD_COVER_BACKUP_MISSING`,不点击删除。备份存在时点击第一张图片的删除按钮,在可见 dialog/modal/popover 中点击删除/确认类按钮,确认图片数减少后再上传新图并拖到第一位。
|
||||||
|
- 执行链路:`apply_task()` 会把任务的 `old_cover_path` 传给 `replace_cover()`,确保③真实封面更新继承①采集备份保护。
|
||||||
|
- 测试:`tests/test_editor_login.py` 新增满 9 张备份缺失阻断、删除确认后上传并拖首位、删除按钮缺失报错、`apply_task()` 传递旧封面备份路径的覆盖。
|
||||||
|
- 文档:`docs/06-tasks.md` 将 T-502 标为 BLOCKED;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/current-state.md`。
|
||||||
|
- 验证:`python -m unittest discover -s tests -p test_editor_login.py` 通过(15 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(104 tests)。未执行真实 Shopee 删除/提交。
|
||||||
|
|
||||||
|
## 【2026-06-29】T-502 真实 9 图商品不提交实测
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 商品:`29671243750`,URL `https://seller.shopee.tw/portal/product/29671243750?pageEntry=product_list&ignore-html-cache=1`,账号别名 `papa`,CDP 端口 `9224`。
|
||||||
|
- 前置:本地旧封面备份存在:`images/papa_f722f20f/29671243750_old.jpg`;商品编辑页初始图片数为 9。
|
||||||
|
- 删除确认框实测:新开测试 tab,执行 `_delete_first_cover()`,结果 `ok=True`,图片数 9 → 8,测试 tab 已关闭,未点击「更新」。
|
||||||
|
- 满 9 张完整换封面实测:新开测试 tab,执行 `replace_cover(..., image=1_TY030.jpg, old_cover_path=备份路径)`,结果 `ok=True`,删除后上传新图,图片数回到 9,新图 `index=0` 且为第一位,测试 tab 已关闭。
|
||||||
|
- 安全边界:全程只操作新开的测试 tab,未点击「更新」保存线上;线上真实保存仍需走 ⑤ 安全开关和 ③ 二次确认。
|
||||||
|
- 文档:`docs/06-tasks.md` 将 T-502 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/current-state.md`。
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -51,6 +52,83 @@ class FakeProductCDP:
|
|||||||
self.closed = True
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def cover_rects(count, prefix="old", start=0):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"i": index,
|
||||||
|
"x": 100 + index * 10,
|
||||||
|
"y": 100,
|
||||||
|
"left": 60 + index * 10,
|
||||||
|
"top": 60,
|
||||||
|
"w": 80,
|
||||||
|
"h": 80,
|
||||||
|
"src": f"https://susercontent.com/{prefix}-{start + index}.jpg",
|
||||||
|
}
|
||||||
|
for index in range(count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCoverCDP:
|
||||||
|
def __init__(self, count=9, delete_click=True, confirm_click=True):
|
||||||
|
self.initial = cover_rects(count)
|
||||||
|
self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1)
|
||||||
|
self.new_rect = {
|
||||||
|
"i": max(0, count - 1),
|
||||||
|
"x": 300,
|
||||||
|
"y": 100,
|
||||||
|
"left": 260,
|
||||||
|
"top": 60,
|
||||||
|
"w": 80,
|
||||||
|
"h": 80,
|
||||||
|
"src": "https://susercontent.com/new-cover.jpg",
|
||||||
|
}
|
||||||
|
self.delete_click = delete_click
|
||||||
|
self.confirm_click = confirm_click
|
||||||
|
self.delete_clicked = False
|
||||||
|
self.confirm_clicked = False
|
||||||
|
self.uploaded = False
|
||||||
|
self.dragged = False
|
||||||
|
self.sent = []
|
||||||
|
self.drag_calls = []
|
||||||
|
|
||||||
|
def _rects(self):
|
||||||
|
if self.uploaded and self.dragged:
|
||||||
|
return [self.new_rect] + self.after_delete
|
||||||
|
if self.uploaded:
|
||||||
|
return self.after_delete + [self.new_rect]
|
||||||
|
if self.confirm_clicked:
|
||||||
|
return self.after_delete
|
||||||
|
return self.initial
|
||||||
|
|
||||||
|
def val(self, expr):
|
||||||
|
if expr == editor.JS_RECTS:
|
||||||
|
return json.dumps(self._rects())
|
||||||
|
if expr == editor.JS_CLICK_FIRST_DELETE:
|
||||||
|
if not self.delete_click:
|
||||||
|
return json.dumps({"clicked": False, "reason": "NO_DELETE_BUTTON"})
|
||||||
|
self.delete_clicked = True
|
||||||
|
return json.dumps({"clicked": True, "reason": None})
|
||||||
|
if expr == editor.JS_CLICK_DELETE_CONFIRM:
|
||||||
|
if not self.confirm_click:
|
||||||
|
return json.dumps({"clicked": False, "reason": "NO_CONFIRM_BUTTON"})
|
||||||
|
self.confirm_clicked = True
|
||||||
|
return json.dumps({"clicked": True, "reason": None, "text": "刪除"})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def object_id(self, expr):
|
||||||
|
return "upload-input"
|
||||||
|
|
||||||
|
def send(self, method, params=None):
|
||||||
|
self.sent.append((method, params or {}))
|
||||||
|
if method == "DOM.setFileInputFiles":
|
||||||
|
self.uploaded = True
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def drag(self, *args, **kwargs):
|
||||||
|
self.dragged = True
|
||||||
|
self.drag_calls.append((args, kwargs))
|
||||||
|
|
||||||
|
|
||||||
def cookie(name, domain=".shopee.tw"):
|
def cookie(name, domain=".shopee.tw"):
|
||||||
return {"name": name, "domain": domain}
|
return {"name": name, "domain": domain}
|
||||||
|
|
||||||
@@ -293,6 +371,84 @@ class EditorLoginTests(unittest.TestCase):
|
|||||||
self.assertTrue(cdp.closed)
|
self.assertTrue(cdp.closed)
|
||||||
close_tab.assert_not_called()
|
close_tab.assert_not_called()
|
||||||
|
|
||||||
|
def test_apply_task_passes_old_cover_backup_to_replace_cover(self):
|
||||||
|
cdp = FakeProductCDP("ws-new")
|
||||||
|
|
||||||
|
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
|
||||||
|
"app.editor.replace_cover",
|
||||||
|
return_value={"ok": True, "reason": None},
|
||||||
|
) as replace_cover, mock.patch(
|
||||||
|
"app.editor.click_update",
|
||||||
|
return_value={"clicked": True, "reason": None},
|
||||||
|
):
|
||||||
|
result = editor.apply_task(
|
||||||
|
{"debug_port": 9222},
|
||||||
|
{
|
||||||
|
"item_id": "51100639510",
|
||||||
|
"new_cover_path": "new.jpg",
|
||||||
|
"old_cover_path": "old.jpg",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["committed"])
|
||||||
|
replace_cover.assert_called_once_with(
|
||||||
|
cdp,
|
||||||
|
"new.jpg",
|
||||||
|
old_cover_path="old.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replace_cover_full_slots_requires_old_cover_backup(self):
|
||||||
|
cdp = FakeCoverCDP(count=9)
|
||||||
|
missing_backup = os.path.join(os.path.dirname(__file__), "missing-old-cover.jpg")
|
||||||
|
|
||||||
|
with mock.patch("app.editor.time.sleep"):
|
||||||
|
result = editor.replace_cover(
|
||||||
|
cdp,
|
||||||
|
__file__,
|
||||||
|
old_cover_path=missing_backup,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual("OLD_COVER_BACKUP_MISSING", result["reason"])
|
||||||
|
self.assertFalse(cdp.delete_clicked)
|
||||||
|
self.assertFalse(cdp.uploaded)
|
||||||
|
|
||||||
|
def test_replace_cover_full_slots_deletes_first_then_uploads_and_drags(self):
|
||||||
|
cdp = FakeCoverCDP(count=9)
|
||||||
|
|
||||||
|
with mock.patch("app.editor.time.sleep"):
|
||||||
|
result = editor.replace_cover(
|
||||||
|
cdp,
|
||||||
|
__file__,
|
||||||
|
old_cover_path=__file__,
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertTrue(cdp.delete_clicked)
|
||||||
|
self.assertTrue(cdp.confirm_clicked)
|
||||||
|
self.assertTrue(cdp.uploaded)
|
||||||
|
self.assertTrue(cdp.dragged)
|
||||||
|
self.assertEqual(9, result["count_before"])
|
||||||
|
self.assertEqual(9, result["count_after"])
|
||||||
|
self.assertEqual(8, result["delete"]["count_after"])
|
||||||
|
self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"])
|
||||||
|
|
||||||
|
def test_replace_cover_full_slots_reports_missing_delete_button(self):
|
||||||
|
cdp = FakeCoverCDP(count=9, delete_click=False)
|
||||||
|
|
||||||
|
with mock.patch("app.editor.time.sleep"):
|
||||||
|
result = editor.replace_cover(
|
||||||
|
cdp,
|
||||||
|
__file__,
|
||||||
|
old_cover_path=__file__,
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual("NO_DELETE_BUTTON", result["reason"])
|
||||||
|
self.assertFalse(cdp.uploaded)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user