From 0cf23db40322f0d9397c3dd8cb0464fbf6eb2878 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 18 Jul 2026 16:34:37 +0800 Subject: [PATCH] feat(collect): persist product status snapshots --- app/db.py | 84 ++++++++++++++++++++++++++- app/editor.py | 32 +++++++++- app/gui/workers.py | 10 ++++ app/product_status.py | 109 +++++++++++++++++++++++++++++++++++ docs/04-architecture.md | 15 ++++- docs/api.md | 10 +++- docs/tasks/T-662a.md | 6 +- tests/test_db.py | 63 ++++++++++++++++++++ tests/test_editor_login.py | 63 +++++++++++++++++++- tests/test_gui.py | 4 ++ tests/test_product_status.py | 80 +++++++++++++++++++++++++ 11 files changed, 464 insertions(+), 12 deletions(-) create mode 100644 app/product_status.py create mode 100644 tests/test_product_status.py diff --git a/app/db.py b/app/db.py index 9bfebeb..fbf08d7 100644 --- a/app/db.py +++ b/app/db.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Iterable, Optional -from . import appconfig +from . import appconfig, product_status from .config import make_slug @@ -30,6 +30,7 @@ FAILURE_STEP_LABELS = { "open_product": "打开商品页", "wait_ready": "页面就绪", "read_title": "读标题", + "read_product_status": "读取商品状态", "read_cover": "读封面", "download_cover": "下载封面", "db_write": "写库", @@ -135,6 +136,9 @@ class Task: account_name: Optional[str] alias: str item_id: str + product_status: Optional[str] + product_status_note: Optional[str] + product_status_at: Optional[str] old_title: Optional[str] old_cover_path: Optional[str] new_title: Optional[str] @@ -232,6 +236,9 @@ CREATE TABLE IF NOT EXISTS tasks ( account_name TEXT, alias TEXT NOT NULL, item_id TEXT NOT NULL, + product_status TEXT, + product_status_note TEXT, + product_status_at TEXT, old_title TEXT, old_cover_path TEXT, new_title TEXT, @@ -485,6 +492,7 @@ def init_db(path=None, conn=None) -> None: _ensure_batch_delete_columns(database) _ensure_task_image_task_columns(database) _ensure_task_cover_reset_columns(database) + _ensure_task_product_status_columns(database) _ensure_image_studio_project_suite_columns(database) _ensure_image_studio_project_draft_columns(database) _ensure_image_studio_job_recovery_columns(database) @@ -516,6 +524,16 @@ def _ensure_task_cover_reset_columns(database): database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") +def _ensure_task_product_status_columns(database): + columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()} + if "product_status" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN product_status TEXT") + if "product_status_note" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN product_status_note TEXT") + if "product_status_at" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN product_status_at TEXT") + + def _ensure_image_studio_project_suite_columns(database): columns = { row["name"] @@ -1021,8 +1039,53 @@ def clear_image_task(task_id, path=None, conn=None) -> None: ) -def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> None: +def set_product_status( + task_id, + product_status_value, + product_status_note=None, + product_status_at=None, + path=None, + conn=None, +) -> None: + """Persist a status snapshot without changing the collection lifecycle.""" + now = _now() + detected_at = product_status_at or now + with _connection(conn, path) as database: + with database: + database.execute( + """ + UPDATE tasks + SET product_status = ?, + product_status_note = ?, + product_status_at = ?, + updated_at = ? + WHERE id = ? + """, + ( + product_status.normalize_status(product_status_value), + str(product_status_note or "")[:2000] or None, + detected_at, + now, + int(task_id), + ), + ) + + +def set_collected( + task_id, + old_title, + old_cover_path, + path=None, + conn=None, + *, + product_status_value=None, + product_status_note=None, + product_status_at=None, +) -> None: + now = _now() + has_status_snapshot = product_status_value is not None + detected_at = product_status_at or now with _connection(conn, path) as database: with database: database.execute( @@ -1035,10 +1098,25 @@ def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> N last_error = NULL, collect_attempts = collect_attempts + 1, collected_at = ?, + product_status = CASE WHEN ? THEN ? ELSE product_status END, + product_status_note = CASE WHEN ? THEN ? ELSE product_status_note END, + product_status_at = CASE WHEN ? THEN ? ELSE product_status_at END, updated_at = ? WHERE id = ? """, - (old_title, old_cover_path, now, now, int(task_id)), + ( + old_title, + old_cover_path, + now, + int(has_status_snapshot), + product_status.normalize_status(product_status_value), + int(has_status_snapshot), + str(product_status_note or "")[:2000] or None, + int(has_status_snapshot), + detected_at, + now, + int(task_id), + ), ) diff --git a/app/editor.py b/app/editor.py index b11783f..3094666 100644 --- a/app/editor.py +++ b/app/editor.py @@ -5,7 +5,7 @@ import os import time from urllib.parse import urlparse -from . import appconfig, image_paths +from . import appconfig, image_paths, product_status from .cdp import ( CDP, close_tab, @@ -112,6 +112,17 @@ JS_RECTS = ( "return JSON.stringify(a);})()" ) +JS_PRODUCT_STATUS_ALERTS = ( + "(function(){" + "var nodes=[].slice.call(document.querySelectorAll('.eds-alert.eds-alert--warning'));" + "return JSON.stringify(nodes.map(function(node){" + "var title=node.querySelector('.eds-alert-title');" + "var desc=node.querySelector('.eds-alert-desc');" + "return {title:(title&&(title.innerText||title.textContent)||'').trim()," + "description:(desc&&(desc.innerText||desc.textContent)||'').trim()};" + "}));})()" +) + JS_UPLOAD_STATE = ( "(function(){" @@ -1153,6 +1164,8 @@ def collect(account, task, on_step=None) -> dict: cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False) result = None try: + _notify_collect_step(on_step, "read_product_status") + status_snapshot = read_product_status(cdp) _notify_collect_step(on_step, "read_title") old_title = read_title(cdp) _notify_collect_step(on_step, "read_cover") @@ -1171,6 +1184,7 @@ def collect(account, task, on_step=None) -> dict: "old_title": old_title, "old_cover_src": old_cover_src, "old_cover_path": old_cover_path, + **status_snapshot, } finally: close_target_confirmed = _close_collected_product(cdp) @@ -1178,6 +1192,22 @@ def collect(account, task, on_step=None) -> dict: return result +def read_product_status(cdp) -> dict: + """Read the current product's warning state without retaining page HTML.""" + + try: + alerts = _json_value(cdp, JS_PRODUCT_STATUS_ALERTS, default=None) + if not isinstance(alerts, list): + raise ValueError("商品状态提示读取结果无效") + snapshot = product_status.classify_alerts(alerts) + snapshot["product_status_error"] = None + return snapshot + except Exception as exc: + snapshot = product_status.classify_alerts(None) + snapshot["product_status_error"] = str(exc) or exc.__class__.__name__ + return snapshot + + def _notify_collect_step(callback, step): if callback is None: diff --git a/app/gui/workers.py b/app/gui/workers.py index 346a2c7..52c1611 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -2152,8 +2152,18 @@ class CollectWorker(BaseWorker): task.id, result.get("old_title", ""), result.get("old_cover_path", ""), + product_status_value=result.get("product_status"), + product_status_note=result.get("product_status_note"), path=self.db_path, ) + if result.get("product_status_error"): + self._write_diagnostic_log( + "商品状态检测失败,已按状态未知保存", + level="WARNING", + step="read_product_status", + task=task, + payload={"error": result.get("product_status_error")}, + ) collected += 1 elapsed_ms = self._elapsed_ms(started) self.row_updated.emit( diff --git a/app/product_status.py b/app/product_status.py new file mode 100644 index 0000000..71f6a92 --- /dev/null +++ b/app/product_status.py @@ -0,0 +1,109 @@ +"""Shared product-status rules for the collection, generation, and apply flows.""" + +from __future__ import annotations + +import re + + +STATUS_NORMAL = "normal" +STATUS_UNLISTED = "unlisted" +STATUS_REVIEWING = "reviewing" +STATUS_UNKNOWN = "unknown" + +VALID_PRODUCT_STATUSES = frozenset( + { + STATUS_NORMAL, + STATUS_UNLISTED, + STATUS_REVIEWING, + STATUS_UNKNOWN, + } +) + +PRODUCT_STATUS_LABELS = { + STATUS_NORMAL: "正常", + STATUS_UNLISTED: "未上架", + STATUS_REVIEWING: "审核中", + STATUS_UNKNOWN: "状态未知", +} + +_WHITESPACE_RE = re.compile(r"\s+") +_NOTE_LIMIT = 2000 + + +def normalize_status(value) -> str: + """Return a stable status code; legacy and invalid values are unknown.""" + + normalized = str(value or "").strip().lower() + if normalized in VALID_PRODUCT_STATUSES: + return normalized + return STATUS_UNKNOWN + + +def status_label(value) -> str: + return PRODUCT_STATUS_LABELS[normalize_status(value)] + + +def is_normal(value) -> bool: + return normalize_status(value) == STATUS_NORMAL + + +def is_known_abnormal(value) -> bool: + return normalize_status(value) in {STATUS_UNLISTED, STATUS_REVIEWING} + + +def normalize_text(value) -> str: + return _WHITESPACE_RE.sub(" ", str(value or "").strip()) + + +def classify_alerts(alerts) -> dict: + """Classify normalized EDS warning-alert data without retaining page HTML.""" + + if not isinstance(alerts, list): + return _snapshot(STATUS_UNKNOWN, "") + if not alerts: + return _snapshot(STATUS_NORMAL, "") + + first_note = "" + valid_alert_seen = False + for alert in alerts: + if not isinstance(alert, dict): + continue + valid_alert_seen = True + title = normalize_text(alert.get("title")) + description = normalize_text(alert.get("description")) + note = _alert_note(title, description) + if not first_note: + first_note = note + if title in {"審核中", "审核中"}: + return _snapshot(STATUS_REVIEWING, note) + if "您的商品未上架" in title: + return _snapshot(STATUS_UNLISTED, note) + + if not valid_alert_seen: + return _snapshot(STATUS_UNKNOWN, "") + return _snapshot(STATUS_UNKNOWN, first_note) + + +def partition_tasks(tasks) -> dict: + """Return tasks grouped by normalized status for callers' run planners.""" + + grouped = {status: [] for status in VALID_PRODUCT_STATUSES} + for task in tasks: + grouped[normalize_status(getattr(task, "product_status", None))].append(task) + return grouped + + +def _alert_note(title: str, description: str) -> str: + parts = [] + if title: + parts.append(f"标题:{title}") + if description: + parts.append(f"说明:{description}") + return ";".join(parts)[:_NOTE_LIMIT] + + +def _snapshot(status: str, note: str) -> dict: + return { + "product_status": normalize_status(status), + "product_status_note": str(note or "")[:_NOTE_LIMIT] or None, + } diff --git a/docs/04-architecture.md b/docs/04-architecture.md index ed26667..06c5e71 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -23,7 +23,8 @@ GUI(PySide6 QTabWidget,当前显示 6 Tab) ├── accounts 账号 CRUD 服务:生成 slug/目录、启动登录、检测登录 ├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式 ├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽) - ├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 + ├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 + ├── product_status 商品状态代码、显示和分组规则 ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写) ├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选) ├── product_suite 套图设置归一化、数量计算、完整提示词与job规划 @@ -75,7 +76,8 @@ imported → collected → generated → applied - `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。 - `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 -- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 +- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 +- `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。 - `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。 - `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。 @@ -265,6 +267,10 @@ CREATE TABLE tasks ( account_name TEXT, alias TEXT NOT NULL, item_id TEXT NOT NULL, + -- 商品状态快照(采集页面探测,不宣称实时在售) + product_status TEXT, -- normal/unlisted/reviewing/unknown;历史 NULL 读作 unknown + product_status_note TEXT, -- 归一化 EDS 标题/说明摘要,最多 2000 字符 + product_status_at TEXT, -- 本次页面状态探测时间 -- 采集输出(程序写,改前快照) old_title TEXT, old_cover_path TEXT, -- 旧封面本地图片路径 @@ -350,6 +356,7 @@ CREATE TABLE run_log_events ( - `account_name` 仅展示/参考;匹配以 `alias` 为准。 - `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。 - `row_key` 防止同一批次内重复导入同一行。 +- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。历史 NULL 与非法值按 `unknown` 处理。 - `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。 - `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。 - `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。 @@ -408,11 +415,12 @@ data/images///__new. # AI 生成的新 ### 6.1 采集(① Tab,只读) -- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 +- 用账号 Chrome 打开商品页,等就绪,先读商品状态,再读旧标题(标题输入框 value)。 - `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,并在最多 2 秒内轮询 `/json` 确认该 target ID 已消失;确认超时只记警告并保留采集成功结果。用户原本已经打开的 tab 不关闭、不等待。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 - ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新是上传、拖拽和线上提交流程,每条任务均以前台方式打开或激活当前商品 tab,优先保障页面交互稳定;后台态失败安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。 - 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。 +- 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`,没有 warning 为 `normal`,未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images///__old.jpg`。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 @@ -500,6 +508,7 @@ data/images///__new. # AI 生成的新 | 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | SPA 就绪 | 不用 load 事件;轮询“唯一商品名称输入框 + 至少一张主图 itembox + 唯一主图上传输入框”三者都在。脚本返回标题命中数、主图/上传入口状态和当前 URL 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。明确商品失效/不存在/无权限类 toast 即使已经隐藏,也优先成为 `open_product` 失败原因并驱动①阶段列显示“商品失效”;其他普通 toast 只有仍可见且属于当前页面 URL 时,才以“页面提示(可能无关)”附加在就绪快照后。已隐藏的物流、备货、库存、价格等编辑校验提示不得覆盖真正缺失的就绪组件;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | +| 商品状态警示 | 编辑页状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得使用 `data-v-*`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`;无 warning → `normal`;未识别 warning、DOM 异常或非法响应 → `unknown`。多个横幅按 DOM 顺序取第一个白名单命中;只保存归一化文本摘要,不保存整段 HTML | | 标题输入框 | 主定位为 `data-product-edit-field-unique-id="name"` 业务字段内唯一可见 `input.eds-input__input`,不再用标题字符数判断身份;仅当该业务字段根不存在时,才回退旧 XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]`。主定位命中多个时明确失败,不猜测写入 | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | diff --git a/docs/api.md b/docs/api.md index 4e35eb2..04c3c68 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,7 +109,10 @@ delete_batch(batch_id, reason=None, path=None) -> dict mark_running(task_id, phase) -> None mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1 mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进 -set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1 +set_product_status(task_id, product_status, product_status_note=None, product_status_at=None) -> None +# 只写商品状态快照,不改变 stage/status/标题/封面;非法状态规范为 unknown +set_collected(task_id, old_title, old_cover_path, path=None, conn=None, *, product_status_value=None, product_status_note=None, product_status_at=None) -> None +# stage=collected,status=success,collect_attempts+1;提供状态快照时与旧标题/封面同一事务写入 set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题 set_generated_cover(task_id, new_cover_path) -> None # 只写AI封面并保留new_title(含NULL);stage=generated,status=success,generate_attempts+1 ensure_image_task_key(task_id) -> str # T-564 cmhub 异步生图:无幂等键则生成并写 tasks.image_task_key @@ -262,10 +265,11 @@ read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, creat open_product(account, item_id) -> CDP # 连端口、导航商品页、等业务字段就绪;标记该 tab 是否本轮自动新建;失败时返回缺失组件/有效错误 toast,并清理本轮自动新建的失败 tab # 采集(只读) +read_product_status(cdp) -> dict # {product_status, product_status_note, product_status_error};仅读 EDS warning,不保存 HTML read_title(cdp) -> str read_cover_src(cdp) -> str # 第一张 itembox 的 img.src download_cover(src, out_path) -> str # 下载旧封面到本地 -collect(account, task) -> dict # -> {old_title, old_cover_path, close_target_confirmed} +collect(account, task) -> dict # -> {old_title, old_cover_path, product_status, product_status_note, product_status_error, close_target_confirmed} # 应用 change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 @@ -286,6 +290,8 @@ apply_task(account, task, close_success_tab=False) -> dict - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 - `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。 +- `read_product_status()` 在读取标题/封面前读取 `.eds-alert.eds-alert--warning` 下的 `.eds-alert-title/.eds-alert-desc`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`,无 warning → `normal`,未识别 warning、DOM 异常或非法响应 → `unknown`;多横幅按 DOM 顺序取第一个白名单命中,不依赖 `data-v-*`,只返回归一化 note,不保存 HTML。状态读取异常不阻断 `collect()`,由调用方写脱敏诊断日志和 `unknown` 快照。 + - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。 - ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。 diff --git a/docs/tasks/T-662a.md b/docs/tasks/T-662a.md index ef67afa..35723b2 100644 --- a/docs/tasks/T-662a.md +++ b/docs/tasks/T-662a.md @@ -1,7 +1,7 @@ --- id: T-662a title: 商品状态领域模型、数据库迁移与采集检测 -status: TODO +status: DONE phase: 7 deps: [] created: 2026-07-18 @@ -89,4 +89,6 @@ git diff --check ## 执行记录 -- 待实现。 +- 2026-07-18:新增 `app/product_status.py`,统一状态代码、中文显示、EDS 警示分类与任务分组;`tasks` 原位迁移新增商品状态快照字段,并提供独立状态写入与 `set_collected()` 的 keyword-only 状态快照参数。 +- 2026-07-18:`editor.collect()` 在读取标题、封面前探测 EDS warning 状态,`CollectWorker` 完整采集成功时同步写入状态快照;状态读取异常降级为 `unknown` 并写脱敏诊断。 +- 验证:`py -3.10 -m unittest tests.test_product_status tests.test_editor_login tests.test_db`、`py -3.10 -m unittest discover -s tests`(600 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过。 diff --git a/tests/test_db.py b/tests/test_db.py index 4047e1b..fede143 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -40,6 +40,9 @@ class DbTests(TempDirMixin, unittest.TestCase): "image_task_key", "cover_reset_count", "cover_reset_at", + "product_status", + "product_status_note", + "product_status_at", }.issubset(task_columns) ) finally: @@ -113,9 +116,13 @@ class DbTests(TempDirMixin, unittest.TestCase): } self.assertIn("cover_reset_count", columns) self.assertIn("cover_reset_at", columns) + self.assertIn("product_status", columns) + self.assertIn("product_status_note", columns) + self.assertIn("product_status_at", columns) task = db.get_task(1, conn=conn) self.assertEqual(0, task.cover_reset_count) self.assertIsNone(task.cover_reset_at) + self.assertIsNone(task.product_status) finally: conn.close() @@ -217,6 +224,62 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_product_status_snapshot_preserves_collection_lifecycle_and_content(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["input.xlsx"], path=db_path) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 2, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639510", + } + ], + path=db_path, + ) + task = db.list_tasks(batch_id=batch_id, path=db_path)[0] + + db.set_collected(task.id, "旧标题", "old.jpg", db_path) + db.set_product_status( + task.id, + "reviewing", + "审核说明", + "2026-07-18T09:00:00", + path=db_path, + ) + status_only = db.get_task(task.id, path=db_path) + self.assertEqual("collected", status_only.stage) + self.assertEqual("success", status_only.status) + self.assertEqual("旧标题", status_only.old_title) + self.assertEqual("old.jpg", status_only.old_cover_path) + self.assertEqual("reviewing", status_only.product_status) + self.assertEqual("审核说明", status_only.product_status_note) + self.assertEqual("2026-07-18T09:00:00", status_only.product_status_at) + + db.set_collected( + task.id, + "重新采集标题", + "new-old.jpg", + product_status_value="not-a-status", + product_status_note="x" * 2200, + product_status_at="2026-07-18T10:00:00", + path=db_path, + ) + refreshed = db.get_task(task.id, path=db_path) + self.assertEqual("unknown", refreshed.product_status) + self.assertEqual("重新采集标题", refreshed.old_title) + self.assertEqual("new-old.jpg", refreshed.old_cover_path) + self.assertEqual("2026-07-18T10:00:00", refreshed.product_status_at) + self.assertEqual(2000, len(refreshed.product_status_note)) + + self.assert_removed(temp_dir) + def test_set_generated_cover_preserves_title_and_records_ai_success(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index f8d9415..a1800d5 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -39,11 +39,12 @@ class FakeCDP: class FakeProductCDP: - def __init__(self, ws, ready=True, toasts=None, rects=None, url=""): + def __init__(self, ws, ready=True, toasts=None, rects=None, alerts=None, url=""): self.ws = ws self.ready = ready self.toasts = list(toasts or []) self.rects = list(rects or []) + self.alerts = list(alerts or []) self.url = url self.closed = False self.sent = [] @@ -63,6 +64,8 @@ class FakeProductCDP: return json.dumps(self.toasts) if expr == editor.JS_RECTS: return json.dumps(self.rects) + if expr == editor.JS_PRODUCT_STATUS_ALERTS: + return json.dumps(self.alerts) return None def send(self, method, params=None): @@ -910,6 +913,64 @@ class EditorLoginTests(unittest.TestCase): timeout=2.0, ) + def test_read_product_status_uses_warning_alerts_without_vue_scope_selectors(self): + cdp = FakeProductCDP( + "ws-status", + alerts=[ + {"title": "无关提示", "description": "保留为未知"}, + {"title": " 審核中 ", "description": " 商品等待审核 "}, + ], + ) + + result = editor.read_product_status(cdp) + + self.assertEqual("reviewing", result["product_status"]) + self.assertEqual("标题:審核中;说明:商品等待审核", result["product_status_note"]) + self.assertIsNone(result["product_status_error"]) + self.assertIn(".eds-alert.eds-alert--warning", editor.JS_PRODUCT_STATUS_ALERTS) + self.assertNotIn("data-v-", editor.JS_PRODUCT_STATUS_ALERTS) + + def test_read_product_status_degrades_to_unknown_when_cdp_response_is_invalid(self): + class BrokenStatusCDP: + def val(self, _expr): + raise RuntimeError("状态读取失败") + + result = editor.read_product_status(BrokenStatusCDP()) + + self.assertEqual("unknown", result["product_status"]) + self.assertIsNone(result["product_status_note"]) + self.assertIn("状态读取失败", result["product_status_error"]) + + def test_collect_reads_product_status_before_title_and_returns_snapshot(self): + cdp = FakeProductCDP("ws-new", alerts=[]) + steps = [] + + def read_title_after_status(_cdp): + self.assertIn("read_product_status", steps) + return "旧标题" + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.read_title", + side_effect=read_title_after_status, + ), mock.patch( + "app.editor.read_cover_src", + return_value="https://down-ws-sg.vod.susercontent.com/cover.jpg", + ), mock.patch( + "app.editor.download_cover", + return_value="images/main/51100639510_old.jpg", + ): + result = editor.collect( + {"debug_port": 9222}, + {"item_id": "51100639510", "old_cover_path": "old.jpg"}, + on_step=steps.append, + ) + + self.assertEqual("normal", result["product_status"]) + self.assertEqual( + ["read_product_status", "read_title", "read_cover", "download_cover"], + steps, + ) + def test_collect_keeps_reused_product_tab_open(self): cdp = FakeProductCDP("ws-existing") cdp.target_id = "target-existing" diff --git a/tests/test_gui.py b/tests/test_gui.py index a099efd..a226b5e 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -8256,6 +8256,8 @@ class GuiTests(TempDirMixin, unittest.TestCase): return { "old_title": "旧标题", "old_cover_path": task["old_cover_path"], + "product_status": "normal", + "product_status_note": None, } worker = CollectWorker( @@ -8308,6 +8310,8 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("success", by_alias["alias-a"].status) self.assertEqual("旧标题", by_alias["alias-a"].old_title) self.assertTrue(by_alias["alias-a"].old_cover_path.endswith(expected_old_cover)) + self.assertEqual("normal", by_alias["alias-a"].product_status) + self.assertIsNotNone(by_alias["alias-a"].product_status_at) self.assertEqual("imported", by_alias["alias-b"].stage) self.assertEqual("skipped", by_alias["alias-b"].status) self.assertIn("账号未登录", by_alias["alias-b"].last_error) diff --git a/tests/test_product_status.py b/tests/test_product_status.py new file mode 100644 index 0000000..b364be2 --- /dev/null +++ b/tests/test_product_status.py @@ -0,0 +1,80 @@ +import html +import re +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app import product_status + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _sample_alert(filename): + source = (REPO_ROOT / "docs" / "html" / filename).read_text(encoding="utf-8") + title_match = re.search(r'class="eds-alert-title">\s*([^<]+)', source) + desc_match = re.search(r'class="eds-alert-desc"[^>]*>(.*?)

', source, re.DOTALL) + if title_match is None or desc_match is None: + raise AssertionError("商品状态样本缺少标题或说明") + description = re.sub(r"<[^>]+>", "", desc_match.group(1)) + return { + "title": html.unescape(title_match.group(1)), + "description": html.unescape(description), + } + + +class ProductStatusTests(unittest.TestCase): + def test_real_warning_samples_are_classified(self): + reviewing = product_status.classify_alerts([_sample_alert("审核中商品提示.html")]) + unlisted = product_status.classify_alerts([_sample_alert("未上架商品提示.html")]) + + self.assertEqual(product_status.STATUS_REVIEWING, reviewing["product_status"]) + self.assertEqual(product_status.STATUS_UNLISTED, unlisted["product_status"]) + + def test_empty_unknown_and_multiple_alerts_follow_contract(self): + self.assertEqual( + product_status.STATUS_NORMAL, + product_status.classify_alerts([])["product_status"], + ) + self.assertEqual( + product_status.STATUS_UNKNOWN, + product_status.classify_alerts([{"title": "其他警告", "description": "说明"}])["product_status"], + ) + result = product_status.classify_alerts( + [ + {"title": "其他警告", "description": "忽略"}, + {"title": "您的商品未上架", "description": "已下架"}, + {"title": "审核中", "description": "后续提示"}, + ] + ) + self.assertEqual(product_status.STATUS_UNLISTED, result["product_status"]) + self.assertIn("您的商品未上架", result["product_status_note"]) + + def test_invalid_values_are_unknown_and_partitioning_is_consistent(self): + tasks = [ + SimpleNamespace(product_status="normal"), + SimpleNamespace(product_status="reviewing"), + SimpleNamespace(product_status=None), + SimpleNamespace(product_status="invalid"), + ] + + grouped = product_status.partition_tasks(tasks) + + self.assertEqual("状态未知", product_status.status_label(None)) + self.assertTrue(product_status.is_normal("normal")) + self.assertFalse(product_status.is_normal(None)) + self.assertTrue(product_status.is_known_abnormal("unlisted")) + self.assertEqual(1, len(grouped["normal"])) + self.assertEqual(2, len(grouped["unknown"])) + + def test_note_is_normalized_and_limited(self): + result = product_status.classify_alerts( + [{"title": " 审核中\n", "description": " " + "x" * 2200}] + ) + + self.assertEqual(product_status.STATUS_REVIEWING, result["product_status"]) + self.assertLessEqual(len(result["product_status_note"]), 2000) + self.assertTrue(result["product_status_note"].startswith("标题:审核中;说明:"))