feat(collect): persist product status snapshots

This commit is contained in:
chengma
2026-07-18 16:34:37 +08:00
parent b4250f36f8
commit 0cf23db403
11 changed files with 464 additions and 12 deletions
+81 -3
View File
@@ -11,7 +11,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Iterable, Optional from typing import Iterable, Optional
from . import appconfig from . import appconfig, product_status
from .config import make_slug from .config import make_slug
@@ -30,6 +30,7 @@ FAILURE_STEP_LABELS = {
"open_product": "打开商品页", "open_product": "打开商品页",
"wait_ready": "页面就绪", "wait_ready": "页面就绪",
"read_title": "读标题", "read_title": "读标题",
"read_product_status": "读取商品状态",
"read_cover": "读封面", "read_cover": "读封面",
"download_cover": "下载封面", "download_cover": "下载封面",
"db_write": "写库", "db_write": "写库",
@@ -135,6 +136,9 @@ class Task:
account_name: Optional[str] account_name: Optional[str]
alias: str alias: str
item_id: str item_id: str
product_status: Optional[str]
product_status_note: Optional[str]
product_status_at: Optional[str]
old_title: Optional[str] old_title: Optional[str]
old_cover_path: Optional[str] old_cover_path: Optional[str]
new_title: Optional[str] new_title: Optional[str]
@@ -232,6 +236,9 @@ CREATE TABLE IF NOT EXISTS tasks (
account_name TEXT, account_name TEXT,
alias TEXT NOT NULL, alias TEXT NOT NULL,
item_id TEXT NOT NULL, item_id TEXT NOT NULL,
product_status TEXT,
product_status_note TEXT,
product_status_at TEXT,
old_title TEXT, old_title TEXT,
old_cover_path TEXT, old_cover_path TEXT,
new_title TEXT, new_title TEXT,
@@ -485,6 +492,7 @@ def init_db(path=None, conn=None) -> None:
_ensure_batch_delete_columns(database) _ensure_batch_delete_columns(database)
_ensure_task_image_task_columns(database) _ensure_task_image_task_columns(database)
_ensure_task_cover_reset_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_suite_columns(database)
_ensure_image_studio_project_draft_columns(database) _ensure_image_studio_project_draft_columns(database)
_ensure_image_studio_job_recovery_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") 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): def _ensure_image_studio_project_suite_columns(database):
columns = { columns = {
row["name"] 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() 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 _connection(conn, path) as database:
with database: with database:
database.execute( database.execute(
@@ -1035,10 +1098,25 @@ def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> N
last_error = NULL, last_error = NULL,
collect_attempts = collect_attempts + 1, collect_attempts = collect_attempts + 1,
collected_at = ?, 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 = ? updated_at = ?
WHERE id = ? 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),
),
) )
+31 -1
View File
@@ -5,7 +5,7 @@ import os
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
from . import appconfig, image_paths from . import appconfig, image_paths, product_status
from .cdp import ( from .cdp import (
CDP, CDP,
close_tab, close_tab,
@@ -112,6 +112,17 @@ JS_RECTS = (
"return JSON.stringify(a);})()" "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 = ( JS_UPLOAD_STATE = (
"(function(){" "(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) cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False)
result = None result = None
try: try:
_notify_collect_step(on_step, "read_product_status")
status_snapshot = read_product_status(cdp)
_notify_collect_step(on_step, "read_title") _notify_collect_step(on_step, "read_title")
old_title = read_title(cdp) old_title = read_title(cdp)
_notify_collect_step(on_step, "read_cover") _notify_collect_step(on_step, "read_cover")
@@ -1171,6 +1184,7 @@ def collect(account, task, on_step=None) -> dict:
"old_title": old_title, "old_title": old_title,
"old_cover_src": old_cover_src, "old_cover_src": old_cover_src,
"old_cover_path": old_cover_path, "old_cover_path": old_cover_path,
**status_snapshot,
} }
finally: finally:
close_target_confirmed = _close_collected_product(cdp) close_target_confirmed = _close_collected_product(cdp)
@@ -1178,6 +1192,22 @@ def collect(account, task, on_step=None) -> dict:
return result 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): def _notify_collect_step(callback, step):
if callback is None: if callback is None:
+10
View File
@@ -2152,8 +2152,18 @@ class CollectWorker(BaseWorker):
task.id, task.id,
result.get("old_title", ""), result.get("old_title", ""),
result.get("old_cover_path", ""), result.get("old_cover_path", ""),
product_status_value=result.get("product_status"),
product_status_note=result.get("product_status_note"),
path=self.db_path, path=self.db_path,
) )
if result.get("product_status_error"):
self._write_diagnostic_log(
"商品状态检测失败,已按状态未知保存",
level="WARNING",
step="read_product_status",
task=task,
payload={"error": result.get("product_status_error")},
)
collected += 1 collected += 1
elapsed_ms = self._elapsed_ms(started) elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit( self.row_updated.emit(
+109
View File
@@ -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,
}
+12 -3
View File
@@ -23,7 +23,8 @@ GUI(PySide6 QTabWidget,当前显示 6 Tab)
├── accounts 账号 CRUD 服务:生成 slug/目录、启动登录、检测登录 ├── accounts 账号 CRUD 服务:生成 slug/目录、启动登录、检测登录
├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式 ├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽) ├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 ├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
├── product_status 商品状态代码、显示和分组规则
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写) ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写)
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选) ├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划 ├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
@@ -75,7 +76,8 @@ imported → collected → generated → applied
- `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。 - `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 - `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 - `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` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。 - `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、日志或导出文件。 - `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
@@ -265,6 +267,10 @@ CREATE TABLE tasks (
account_name TEXT, account_name TEXT,
alias TEXT NOT NULL, alias TEXT NOT NULL,
item_id 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_title TEXT,
old_cover_path TEXT, -- 旧封面本地图片路径 old_cover_path TEXT, -- 旧封面本地图片路径
@@ -350,6 +356,7 @@ CREATE TABLE run_log_events (
- `account_name` 仅展示/参考;匹配以 `alias` 为准。 - `account_name` 仅展示/参考;匹配以 `alias` 为准。
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。 - `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
- `row_key` 防止同一批次内重复导入同一行。 - `row_key` 防止同一批次内重复导入同一行。
- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。历史 NULL 与非法值按 `unknown` 处理。
- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。 - `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
- `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。 - `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`。 - `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
@@ -408,11 +415,12 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
### 6.1 采集(① Tab,只读) ### 6.1 采集(① Tab,只读)
- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 - 用账号 Chrome 打开商品页,等就绪,先读商品状态,再读旧标题(标题输入框 value)。
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,并在最多 2 秒内轮询 `/json` 确认该 target ID 已消失;确认超时只记警告并保留采集成功结果。用户原本已经打开的 tab 不关闭、不等待。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 - `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,优先保障页面交互稳定;后台态失败安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。 - ①采集调用 `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 页面。 - 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。
- 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`,没有 warning 为 `normal`,未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
@@ -500,6 +508,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
| 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
| SPA 就绪 | 不用 load 事件;轮询“唯一商品名称输入框 + 至少一张主图 itembox + 唯一主图上传输入框”三者都在。脚本返回标题命中数、主图/上传入口状态和当前 URL 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 | | 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、未登录、页面超时、风控等其他失败仍显示“失败” | | 商品页错误 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]`。主定位命中多个时明确失败,不猜测写入 | | 标题输入框 | 主定位为 `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`==新值 | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
+8 -2
View File
@@ -109,7 +109,10 @@ delete_batch(batch_id, reason=None, path=None) -> dict
mark_running(task_id, phase) -> None mark_running(task_id, phase) -> None
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1 mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进 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(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 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 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 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_title(cdp) -> str
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
download_cover(src, out_path) -> str # 下载旧封面到本地 download_cover(src, out_path) -> str # 下载旧封面到本地
collect(account, task) -> dict # -> {old_title, old_cover_path, 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},要求三者相等 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()` 若复用已存在商品 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。 - `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 不关闭、不等待。 - `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 秒。 - ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。
+4 -2
View File
@@ -1,7 +1,7 @@
--- ---
id: T-662a id: T-662a
title: 商品状态领域模型、数据库迁移与采集检测 title: 商品状态领域模型、数据库迁移与采集检测
status: TODO status: DONE
phase: 7 phase: 7
deps: [] deps: []
created: 2026-07-18 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` 均通过。
+63
View File
@@ -40,6 +40,9 @@ class DbTests(TempDirMixin, unittest.TestCase):
"image_task_key", "image_task_key",
"cover_reset_count", "cover_reset_count",
"cover_reset_at", "cover_reset_at",
"product_status",
"product_status_note",
"product_status_at",
}.issubset(task_columns) }.issubset(task_columns)
) )
finally: finally:
@@ -113,9 +116,13 @@ class DbTests(TempDirMixin, unittest.TestCase):
} }
self.assertIn("cover_reset_count", columns) self.assertIn("cover_reset_count", columns)
self.assertIn("cover_reset_at", 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) task = db.get_task(1, conn=conn)
self.assertEqual(0, task.cover_reset_count) self.assertEqual(0, task.cover_reset_count)
self.assertIsNone(task.cover_reset_at) self.assertIsNone(task.cover_reset_at)
self.assertIsNone(task.product_status)
finally: finally:
conn.close() conn.close()
@@ -217,6 +224,62 @@ class DbTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_set_generated_cover_preserves_title_and_records_ai_success(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
+62 -1
View File
@@ -39,11 +39,12 @@ class FakeCDP:
class FakeProductCDP: 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.ws = ws
self.ready = ready self.ready = ready
self.toasts = list(toasts or []) self.toasts = list(toasts or [])
self.rects = list(rects or []) self.rects = list(rects or [])
self.alerts = list(alerts or [])
self.url = url self.url = url
self.closed = False self.closed = False
self.sent = [] self.sent = []
@@ -63,6 +64,8 @@ class FakeProductCDP:
return json.dumps(self.toasts) return json.dumps(self.toasts)
if expr == editor.JS_RECTS: if expr == editor.JS_RECTS:
return json.dumps(self.rects) return json.dumps(self.rects)
if expr == editor.JS_PRODUCT_STATUS_ALERTS:
return json.dumps(self.alerts)
return None return None
def send(self, method, params=None): def send(self, method, params=None):
@@ -910,6 +913,64 @@ class EditorLoginTests(unittest.TestCase):
timeout=2.0, 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): def test_collect_keeps_reused_product_tab_open(self):
cdp = FakeProductCDP("ws-existing") cdp = FakeProductCDP("ws-existing")
cdp.target_id = "target-existing" cdp.target_id = "target-existing"
+4
View File
@@ -8256,6 +8256,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
return { return {
"old_title": "旧标题", "old_title": "旧标题",
"old_cover_path": task["old_cover_path"], "old_cover_path": task["old_cover_path"],
"product_status": "normal",
"product_status_note": None,
} }
worker = CollectWorker( worker = CollectWorker(
@@ -8308,6 +8310,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("success", by_alias["alias-a"].status) self.assertEqual("success", by_alias["alias-a"].status)
self.assertEqual("旧标题", by_alias["alias-a"].old_title) self.assertEqual("旧标题", by_alias["alias-a"].old_title)
self.assertTrue(by_alias["alias-a"].old_cover_path.endswith(expected_old_cover)) 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("imported", by_alias["alias-b"].stage)
self.assertEqual("skipped", by_alias["alias-b"].status) self.assertEqual("skipped", by_alias["alias-b"].status)
self.assertIn("账号未登录", by_alias["alias-b"].last_error) self.assertIn("账号未登录", by_alias["alias-b"].last_error)
+80
View File
@@ -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"[^>]*>(.*?)</p>', 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("标题:审核中;说明:"))