202 lines
8.8 KiB
Python
202 lines
8.8 KiB
Python
"""生成经营者首屏使用的今日必看混排。"""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from datetime import date
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
|
||
|
|
TODAY_LIMIT = 24
|
||
|
|
SECTION_LIMITS = {
|
||
|
|
"calendar": 5,
|
||
|
|
"exhibition": 5,
|
||
|
|
"policy": 4,
|
||
|
|
"standard": 4,
|
||
|
|
"activity": 6,
|
||
|
|
}
|
||
|
|
TREND_CATEGORIES = ("trend_blogs", "fashion_trends", "textile_gallery")
|
||
|
|
TREND_KEYWORDS = (
|
||
|
|
"家纺", "家居", "床品", "床垫", "被子", "毛毯", "毯", "枕", "窗帘", "地毯",
|
||
|
|
"面料", "纺织", "织物", "羊毛", "棉花", "亚麻", "丝绸", "灯芯绒", "材质",
|
||
|
|
"材料", "色彩", "颜色", "图案", "纹样", "印花", "刺绣", "织造", "可持续",
|
||
|
|
"home textile", "fabric", "material", "colour", "color", "pattern", "weave",
|
||
|
|
"wool", "cotton", "linen", "silk", "rug", "carpet", "curtain", "bedding",
|
||
|
|
"blanket", "pillow", "interior",
|
||
|
|
)
|
||
|
|
TREND_MATERIAL_KEYWORDS = (
|
||
|
|
"家纺", "床品", "床垫", "被子", "毛毯", "窗帘", "地毯", "面料", "纺织", "织物",
|
||
|
|
"羊毛", "棉花", "亚麻", "丝绸", "灯芯绒", "织造", "可持续", "home textile",
|
||
|
|
"fabric", "textile", "wool", "cotton", "linen", "silk", "rug", "carpet",
|
||
|
|
"curtain", "bedding", "blanket", "pillow",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def load_trends(path: Path) -> dict[str, Any]:
|
||
|
|
try:
|
||
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
|
|
return payload if isinstance(payload, dict) else {}
|
||
|
|
except (OSError, json.JSONDecodeError):
|
||
|
|
return {}
|
||
|
|
|
||
|
|
|
||
|
|
def _date_value(value: Any) -> date | None:
|
||
|
|
try:
|
||
|
|
return date.fromisoformat(str(value or "")[:10])
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _trend_candidates(now: date, trends: dict[str, Any] | None) -> list[tuple[int, str, dict[str, Any]]]:
|
||
|
|
candidates: list[tuple[int, str, dict[str, Any]]] = []
|
||
|
|
categories = (trends or {}).get("categories") or {}
|
||
|
|
verified_at = (trends or {}).get("generated_at") or ""
|
||
|
|
actions = {
|
||
|
|
"trend_blogs": "判断其对家纺面料、成本或消费方向的影响,并记录需要验证的选品假设",
|
||
|
|
"fashion_trends": "提取可迁移到家纺的色彩、材质和图案要点,形成打样任务",
|
||
|
|
"textile_gallery": "从纹样、配色和织造细节中提取一组可落地的产品灵感",
|
||
|
|
}
|
||
|
|
targets = {
|
||
|
|
"trend_blogs": ["市场变化", "面料与成本", "消费方向"],
|
||
|
|
"fashion_trends": ["色彩", "材质", "图案"],
|
||
|
|
"textile_gallery": ["纹样", "配色", "织造细节"],
|
||
|
|
}
|
||
|
|
bases = {"trend_blogs": 840, "fashion_trends": 830, "textile_gallery": 790}
|
||
|
|
|
||
|
|
for category_key in TREND_CATEGORIES:
|
||
|
|
category = categories.get(category_key) or {}
|
||
|
|
items = category.get("items") if isinstance(category, dict) else []
|
||
|
|
for raw in items or []:
|
||
|
|
raw_id = str(raw.get("id") or "")
|
||
|
|
if not raw_id:
|
||
|
|
continue
|
||
|
|
title_text = " ".join(str(raw.get(key) or "") for key in ("title", "title_original")).lower()
|
||
|
|
summary_text = " ".join(str(raw.get(key) or "") for key in ("summary", "summary_original")).lower()
|
||
|
|
title_hits = sum(keyword in title_text for keyword in TREND_KEYWORDS)
|
||
|
|
summary_hits = sum(keyword in summary_text for keyword in TREND_KEYWORDS)
|
||
|
|
if category_key != "textile_gallery" and not title_hits and not summary_hits:
|
||
|
|
continue
|
||
|
|
if category_key == "trend_blogs" and not any(keyword in f"{title_text} {summary_text}" for keyword in TREND_MATERIAL_KEYWORDS):
|
||
|
|
continue
|
||
|
|
published = _date_value(raw.get("published_at"))
|
||
|
|
age = max(0, (now - published).days) if published else 30
|
||
|
|
if category_key != "textile_gallery" and age > 45:
|
||
|
|
continue
|
||
|
|
recency = max(0, 30 - min(age, 30))
|
||
|
|
score = bases[category_key] + min(title_hits, 3) * 30 + min(summary_hits, 4) * 5 + recency + (20 if raw.get("thumbnail") else 0)
|
||
|
|
item = dict(raw)
|
||
|
|
item.update({
|
||
|
|
"id": f"trend-{raw_id}",
|
||
|
|
"section": "standard",
|
||
|
|
"source_id": f"trend_{category_key}",
|
||
|
|
"trend_category_key": category_key,
|
||
|
|
"image": raw.get("thumbnail") or "",
|
||
|
|
"region": "全球",
|
||
|
|
"authority": "公开趋势来源",
|
||
|
|
"verified_at": verified_at,
|
||
|
|
"product_targets": targets[category_key],
|
||
|
|
"today_action": actions[category_key],
|
||
|
|
})
|
||
|
|
candidates.append((score, "standard", item))
|
||
|
|
return candidates
|
||
|
|
|
||
|
|
|
||
|
|
def build_today(
|
||
|
|
now: date,
|
||
|
|
calendar: list[dict[str, Any]],
|
||
|
|
exhibitions: list[dict[str, Any]],
|
||
|
|
policies: list[dict[str, Any]],
|
||
|
|
standards: list[dict[str, Any]],
|
||
|
|
activities: list[dict[str, Any]] | None = None,
|
||
|
|
trends: dict[str, Any] | None = None,
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
candidates: list[tuple[int, str, dict[str, Any]]] = []
|
||
|
|
|
||
|
|
for item in exhibitions:
|
||
|
|
start = _date_value(item.get("start_date"))
|
||
|
|
if start is None:
|
||
|
|
continue
|
||
|
|
days = (start - now).days
|
||
|
|
if 0 <= days <= 21:
|
||
|
|
item["today_action"] = item.get("attendance_recommendation") or "核对参展或观展安排"
|
||
|
|
candidates.append((900 - days, "exhibition", item))
|
||
|
|
|
||
|
|
for item in calendar:
|
||
|
|
start = _date_value(item.get("start_date"))
|
||
|
|
stocking_start = _date_value(item.get("stocking_start"))
|
||
|
|
stocking_end = _date_value(item.get("stocking_end") or item.get("start_date"))
|
||
|
|
if not start or not stocking_start or not stocking_end:
|
||
|
|
continue
|
||
|
|
days = (start - now).days
|
||
|
|
actionable = (
|
||
|
|
item.get("business_relevance") in ("high", "medium")
|
||
|
|
and item.get("affected_countries")
|
||
|
|
and item.get("platforms")
|
||
|
|
and item.get("product_suggestions")
|
||
|
|
and stocking_start <= now <= stocking_end
|
||
|
|
)
|
||
|
|
if 0 <= days <= 180 and actionable:
|
||
|
|
item["today_action"] = item.get("next_action") or "核对备货与入仓计划"
|
||
|
|
candidates.append((850 + int(item.get("priority_score") or 0) - min(days, 180), "calendar", item))
|
||
|
|
|
||
|
|
for item in policies:
|
||
|
|
if item.get("policy_type") == "产品安全与召回" and item.get("change_status") != "新发布召回":
|
||
|
|
continue
|
||
|
|
if item.get("change_status") not in ("新发布召回", "检测到近期更新"):
|
||
|
|
continue
|
||
|
|
item["today_action"] = item.get("action") or "核对官方规则与受影响商品"
|
||
|
|
candidates.append((900 if item.get("risk_level") == "高" else 780, "policy", item))
|
||
|
|
|
||
|
|
for item in standards:
|
||
|
|
item["today_action"] = item.get("application") or "转成打样或质检任务"
|
||
|
|
candidates.append((520, "standard", item))
|
||
|
|
|
||
|
|
for item in activities or []:
|
||
|
|
start = _date_value(item.get("start_date"))
|
||
|
|
if start is None:
|
||
|
|
continue
|
||
|
|
days = (start - now).days
|
||
|
|
relevance = item.get("relevance")
|
||
|
|
max_days = 30 if relevance == "direct" else 14
|
||
|
|
if 0 <= days <= max_days and relevance in ("direct", "must"):
|
||
|
|
item["today_action"] = item.get("next_action") or "核对议程、报名截止与适用市场"
|
||
|
|
relevance_score = 70 if relevance == "direct" else 45
|
||
|
|
official_score = 25 if item.get("official_activity") else 0
|
||
|
|
candidates.append((820 + relevance_score + official_score - days, "activity", item))
|
||
|
|
|
||
|
|
candidates.extend(_trend_candidates(now, trends))
|
||
|
|
candidates.sort(key=lambda row: (-row[0], str(row[2].get("title") or "")))
|
||
|
|
|
||
|
|
selected: list[tuple[int, str, dict[str, Any]]] = []
|
||
|
|
selected_ids: set[str] = set()
|
||
|
|
section_counts = {section: 0 for section in SECTION_LIMITS}
|
||
|
|
|
||
|
|
def add(row: tuple[int, str, dict[str, Any]]) -> None:
|
||
|
|
_score, section, item = row
|
||
|
|
item_id = str(item.get("id") or "")
|
||
|
|
if not item_id or item_id in selected_ids or len(selected) >= TODAY_LIMIT:
|
||
|
|
return
|
||
|
|
if section_counts[section] >= SECTION_LIMITS[section]:
|
||
|
|
return
|
||
|
|
selected.append(row)
|
||
|
|
selected_ids.add(item_id)
|
||
|
|
section_counts[section] += 1
|
||
|
|
|
||
|
|
for category_key in TREND_CATEGORIES:
|
||
|
|
row = next((candidate for candidate in candidates if candidate[2].get("trend_category_key") == category_key), None)
|
||
|
|
if row:
|
||
|
|
add(row)
|
||
|
|
for section in ("policy", "calendar", "exhibition", "activity"):
|
||
|
|
row = next((candidate for candidate in candidates if candidate[1] == section), None)
|
||
|
|
if row:
|
||
|
|
add(row)
|
||
|
|
row = next((candidate for candidate in candidates if candidate[1] == "standard" and not candidate[2].get("trend_category_key")), None)
|
||
|
|
if row:
|
||
|
|
add(row)
|
||
|
|
for row in candidates:
|
||
|
|
add(row)
|
||
|
|
|
||
|
|
selected.sort(key=lambda row: (-row[0], str(row[2].get("title") or "")))
|
||
|
|
return [item for _score, _section, item in selected]
|