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,
|
||||
}
|
||||
Reference in New Issue
Block a user