feat(collect): persist product status snapshots
This commit is contained in:
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+31
-1
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # 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/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
|
||||
- 写 `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`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
|
||||
| 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`==新值 |
|
||||
|
||||
+8
-2
@@ -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。
|
||||
|
||||
@@ -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` 均通过。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("标题:审核中;说明:"))
|
||||
Reference in New Issue
Block a user