2026-07-18 16:34:37 +08:00
|
|
|
|
"""Shared product-status rules for the collection, generation, and apply flows."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-07-18 16:57:57 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
2026-07-18 16:34:37 +08:00
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
STATUS_NORMAL = "normal"
|
|
|
|
|
|
STATUS_UNLISTED = "unlisted"
|
|
|
|
|
|
STATUS_REVIEWING = "reviewing"
|
|
|
|
|
|
STATUS_UNKNOWN = "unknown"
|
2026-07-20 10:16:25 +08:00
|
|
|
|
STATUS_UNCHECKED = "unchecked"
|
2026-07-18 16:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
VALID_PRODUCT_STATUSES = frozenset(
|
|
|
|
|
|
{
|
|
|
|
|
|
STATUS_NORMAL,
|
|
|
|
|
|
STATUS_UNLISTED,
|
|
|
|
|
|
STATUS_REVIEWING,
|
|
|
|
|
|
STATUS_UNKNOWN,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
PRODUCT_STATUS_LABELS = {
|
|
|
|
|
|
STATUS_NORMAL: "正常",
|
|
|
|
|
|
STATUS_UNLISTED: "未上架",
|
|
|
|
|
|
STATUS_REVIEWING: "审核中",
|
|
|
|
|
|
STATUS_UNKNOWN: "状态未知",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 10:16:25 +08:00
|
|
|
|
PRODUCT_STATUS_FILTER_ITEMS = (
|
|
|
|
|
|
("全部商品状态", "all"),
|
|
|
|
|
|
("架上商品", STATUS_NORMAL),
|
|
|
|
|
|
("未上架", STATUS_UNLISTED),
|
|
|
|
|
|
("审核中", STATUS_REVIEWING),
|
|
|
|
|
|
("状态未知", STATUS_UNKNOWN),
|
|
|
|
|
|
("待检测", STATUS_UNCHECKED),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
PRODUCT_STATUS_DISPLAY_LABELS = {
|
|
|
|
|
|
STATUS_NORMAL: "架上商品",
|
|
|
|
|
|
STATUS_UNLISTED: "未上架",
|
|
|
|
|
|
STATUS_REVIEWING: "审核中",
|
|
|
|
|
|
STATUS_UNKNOWN: "状态未知",
|
|
|
|
|
|
STATUS_UNCHECKED: "待检测",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 16:46:59 +08:00
|
|
|
|
SCOPE_NORMAL_ONLY = "normal_only"
|
|
|
|
|
|
SCOPE_ALL = "all"
|
|
|
|
|
|
|
|
|
|
|
|
# Kept as explicit collection aliases while generation and apply adopt the
|
|
|
|
|
|
# shared scope values in their own tasks.
|
|
|
|
|
|
COLLECT_SCOPE_NORMAL_ONLY = SCOPE_NORMAL_ONLY
|
|
|
|
|
|
COLLECT_SCOPE_ALL = SCOPE_ALL
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
_WHITESPACE_RE = re.compile(r"\s+")
|
|
|
|
|
|
_NOTE_LIMIT = 2000
|
2026-07-20 10:52:32 +08:00
|
|
|
|
_PROMOTION_EDIT_RESTRICTION_MARKERS = (
|
|
|
|
|
|
("促銷", "無法進行編輯"),
|
|
|
|
|
|
("促销", "无法进行编辑"),
|
|
|
|
|
|
)
|
2026-07-18 16:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 10:16:25 +08:00
|
|
|
|
def display_status(value) -> str:
|
|
|
|
|
|
"""Return the UI label without treating an empty snapshot as unknown."""
|
|
|
|
|
|
|
|
|
|
|
|
return PRODUCT_STATUS_DISPLAY_LABELS[raw_status(value)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def raw_status(value) -> str:
|
|
|
|
|
|
"""Return a UI filter status while keeping blank snapshots distinguishable."""
|
|
|
|
|
|
|
|
|
|
|
|
if not str(value or "").strip():
|
|
|
|
|
|
return STATUS_UNCHECKED
|
|
|
|
|
|
return normalize_status(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 10:52:32 +08:00
|
|
|
|
def is_promotion_edit_restriction_alert(title, description="") -> bool:
|
|
|
|
|
|
"""Return whether an alert only describes a promotion editing limitation."""
|
|
|
|
|
|
|
|
|
|
|
|
text = normalize_text(f"{title or ''} {description or ''}")
|
|
|
|
|
|
return any(
|
|
|
|
|
|
promotion_marker in text and edit_marker in text
|
|
|
|
|
|
for promotion_marker, edit_marker in _PROMOTION_EDIT_RESTRICTION_MARKERS
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_promotion_edit_restriction_note(note) -> bool:
|
|
|
|
|
|
"""Recognize the persisted summary generated from a known benign alert."""
|
|
|
|
|
|
|
|
|
|
|
|
return is_promotion_edit_restriction_alert(note)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
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, "")
|
|
|
|
|
|
|
|
|
|
|
|
valid_alert_seen = False
|
2026-07-20 10:52:32 +08:00
|
|
|
|
unknown_alert_note = ""
|
2026-07-18 16:34:37 +08:00
|
|
|
|
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 title in {"審核中", "审核中"}:
|
|
|
|
|
|
return _snapshot(STATUS_REVIEWING, note)
|
|
|
|
|
|
if "您的商品未上架" in title:
|
|
|
|
|
|
return _snapshot(STATUS_UNLISTED, note)
|
2026-07-20 10:52:32 +08:00
|
|
|
|
if is_promotion_edit_restriction_alert(title, description):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not unknown_alert_note:
|
|
|
|
|
|
unknown_alert_note = note
|
2026-07-18 16:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
if not valid_alert_seen:
|
|
|
|
|
|
return _snapshot(STATUS_UNKNOWN, "")
|
2026-07-20 10:52:32 +08:00
|
|
|
|
if not unknown_alert_note:
|
|
|
|
|
|
return _snapshot(STATUS_NORMAL, "")
|
|
|
|
|
|
return _snapshot(STATUS_UNKNOWN, unknown_alert_note)
|
2026-07-18 16:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:57:57 +08:00
|
|
|
|
def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict:
|
|
|
|
|
|
"""Build a frozen, status-aware generation plan without mutating tasks."""
|
|
|
|
|
|
|
|
|
|
|
|
base_candidates = _deduplicate_tasks(tasks)
|
|
|
|
|
|
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
|
|
|
|
|
for task in base_candidates:
|
|
|
|
|
|
status_counts[_task_status(task)] += 1
|
|
|
|
|
|
|
|
|
|
|
|
normalized_scope = normalize_scope(scope)
|
|
|
|
|
|
if normalized_scope == SCOPE_ALL:
|
|
|
|
|
|
execution_tasks = list(base_candidates)
|
|
|
|
|
|
else:
|
|
|
|
|
|
execution_tasks = [
|
|
|
|
|
|
task for task in base_candidates if is_normal(_task_status(task))
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"base_candidates": base_candidates,
|
|
|
|
|
|
"status_counts": status_counts,
|
|
|
|
|
|
"execution_tasks": execution_tasks,
|
|
|
|
|
|
"scope": normalized_scope,
|
|
|
|
|
|
"scope_excluded": len(base_candidates) - len(execution_tasks),
|
2026-07-18 17:10:12 +08:00
|
|
|
|
"fingerprint": _task_plan_fingerprint(base_candidates, generate_mode),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_apply_plan(tasks, update_mode) -> dict:
|
|
|
|
|
|
"""Return a status-first, content-aware plan for Shopee update actions."""
|
|
|
|
|
|
|
|
|
|
|
|
base_candidates = _deduplicate_tasks(tasks)
|
|
|
|
|
|
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
|
|
|
|
|
status_excluded = {
|
|
|
|
|
|
STATUS_UNLISTED: [],
|
|
|
|
|
|
STATUS_REVIEWING: [],
|
|
|
|
|
|
STATUS_UNKNOWN: [],
|
|
|
|
|
|
}
|
|
|
|
|
|
normal_candidates = []
|
|
|
|
|
|
for task in base_candidates:
|
|
|
|
|
|
status = _task_status(task)
|
|
|
|
|
|
status_counts[status] += 1
|
|
|
|
|
|
if status == STATUS_NORMAL:
|
|
|
|
|
|
normal_candidates.append(task)
|
|
|
|
|
|
else:
|
|
|
|
|
|
status_excluded[status].append(task)
|
|
|
|
|
|
|
|
|
|
|
|
mode = str(update_mode or "").strip().lower()
|
|
|
|
|
|
requires_title = mode in {"title", "title_cover"}
|
|
|
|
|
|
requires_cover = mode in {"cover", "title_cover"}
|
|
|
|
|
|
missing_title = [
|
|
|
|
|
|
task
|
|
|
|
|
|
for task in normal_candidates
|
|
|
|
|
|
if requires_title and not str(_task_value(task, "new_title") or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
missing_cover = [
|
|
|
|
|
|
task
|
|
|
|
|
|
for task in normal_candidates
|
|
|
|
|
|
if requires_cover and not str(_task_value(task, "new_cover_path") or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
content_excluded_ids = {id(task) for task in missing_title + missing_cover}
|
|
|
|
|
|
executable_tasks = [
|
|
|
|
|
|
task for task in normal_candidates if id(task) not in content_excluded_ids
|
|
|
|
|
|
]
|
|
|
|
|
|
status_excluded_count = sum(len(rows) for rows in status_excluded.values())
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"base_candidates": base_candidates,
|
|
|
|
|
|
"status_counts": status_counts,
|
|
|
|
|
|
"executable": executable_tasks,
|
|
|
|
|
|
"unlisted": status_excluded[STATUS_UNLISTED],
|
|
|
|
|
|
"reviewing": status_excluded[STATUS_REVIEWING],
|
|
|
|
|
|
"unknown": status_excluded[STATUS_UNKNOWN],
|
|
|
|
|
|
"missing_title": missing_title,
|
|
|
|
|
|
"missing_cover": missing_cover,
|
|
|
|
|
|
"status_scope_excluded": status_excluded_count,
|
|
|
|
|
|
"content_scope_excluded": len(content_excluded_ids),
|
|
|
|
|
|
"scope_excluded": status_excluded_count + len(content_excluded_ids),
|
|
|
|
|
|
"fingerprint": _task_plan_fingerprint(base_candidates, update_mode),
|
2026-07-18 16:57:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:46:59 +08:00
|
|
|
|
def normalize_collect_scope(value) -> str:
|
2026-07-18 16:57:57 +08:00
|
|
|
|
return normalize_scope(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_scope(value) -> str:
|
2026-07-18 16:46:59 +08:00
|
|
|
|
value = str(value or "").strip().lower()
|
|
|
|
|
|
return SCOPE_ALL if value == SCOPE_ALL else SCOPE_NORMAL_ONLY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def should_collect_content(status, scope) -> bool:
|
|
|
|
|
|
return normalize_collect_scope(scope) == COLLECT_SCOPE_ALL or is_normal(status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect_skip_reason(status) -> str:
|
|
|
|
|
|
return f"{status_label(status)},按本轮范围略过"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:57:57 +08:00
|
|
|
|
def _deduplicate_tasks(tasks) -> list:
|
|
|
|
|
|
seen = set()
|
|
|
|
|
|
unique = []
|
|
|
|
|
|
for task in list(tasks or []):
|
|
|
|
|
|
task_id = _task_value(task, "id")
|
|
|
|
|
|
key = ("id", str(task_id)) if task_id is not None else ("object", id(task))
|
|
|
|
|
|
if key in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(key)
|
|
|
|
|
|
unique.append(task)
|
|
|
|
|
|
return unique
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 17:10:12 +08:00
|
|
|
|
def _task_plan_fingerprint(tasks, mode) -> str:
|
2026-07-18 16:57:57 +08:00
|
|
|
|
snapshots = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"task_id": _task_value(task, "id"),
|
|
|
|
|
|
"updated_at": _task_value(task, "updated_at"),
|
|
|
|
|
|
"product_status": _task_status(task),
|
|
|
|
|
|
"new_title": _task_value(task, "new_title"),
|
|
|
|
|
|
"new_cover_path": _task_value(task, "new_cover_path"),
|
2026-07-18 17:10:12 +08:00
|
|
|
|
"mode": str(mode or ""),
|
2026-07-18 16:57:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
for task in tasks
|
|
|
|
|
|
]
|
|
|
|
|
|
encoded = json.dumps(
|
|
|
|
|
|
snapshots,
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
|
default=str,
|
|
|
|
|
|
).encode("utf-8")
|
|
|
|
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _task_status(task) -> str:
|
|
|
|
|
|
return normalize_status(_task_value(task, "product_status"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _task_value(task, name, default=None):
|
|
|
|
|
|
if isinstance(task, dict):
|
|
|
|
|
|
return task.get(name, default)
|
|
|
|
|
|
return getattr(task, name, default)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|