"""Shared product-status rules for the collection, generation, and apply flows.""" from __future__ import annotations import hashlib import json import re STATUS_NORMAL = "normal" STATUS_UNLISTED = "unlisted" STATUS_REVIEWING = "reviewing" STATUS_UNKNOWN = "unknown" STATUS_UNCHECKED = "unchecked" VALID_PRODUCT_STATUSES = frozenset( { STATUS_NORMAL, STATUS_UNLISTED, STATUS_REVIEWING, STATUS_UNKNOWN, } ) PRODUCT_STATUS_LABELS = { STATUS_NORMAL: "正常", STATUS_UNLISTED: "未上架", STATUS_REVIEWING: "审核中", STATUS_UNKNOWN: "状态未知", } 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: "待检测", } 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 _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 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) 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 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), "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), } def normalize_collect_scope(value) -> str: return normalize_scope(value) def normalize_scope(value) -> str: 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)},按本轮范围略过" 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 def _task_plan_fingerprint(tasks, mode) -> str: 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"), "mode": str(mode or ""), } 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) 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, }