feat: 发布三笙AI情报局独立版公司研发基线
同步家纺情报 v2 数据、59 个严格权威源、趋势图库、移动端交互、Google/Yandex 翻译门控及静态 SEO/GEO 构建。 补充面向 C# 研发的 README,明确当前无 LLM、多模态与生图模型,记录翻译请求参数、缓存与失败策略、数据处理边界、前端职责和无 GitHub Pages 部署流程。 新增按日期倒序维护的 CHANGELOG,记录影响、验证与代码/数据/dist 回滚方法。 验证:11 个 Python 契约测试、13 个 Node 前端测试通过;静态构建生成 1096 个 Sitemap URL。
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为家纺情报生成无需 JavaScript 的静态 SEO 页面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from seo_config import BRAND_NAME, answer_first, keywords_for
|
||||
|
||||
|
||||
DEFAULT_SITE_URL = "http://127.0.0.1:8765"
|
||||
SECTION_CONFIG = {
|
||||
"calendar": ("市场备货日历", "按国家、地区与消费场景聚合的家纺备货决策情报"),
|
||||
"exhibitions": ("展会通", "国内外家纺、纺织、面料与家居展会情报"),
|
||||
"policies": ("政策预警", "跨境电商、运输、关税、合规与知识产权预警"),
|
||||
"standards": ("趋势雷达", "色彩、材料、认证与纺织测试标准信号"),
|
||||
"platforms": ("平台渠道", "重点跨境平台、家纺类目、入驻条件与官方入口"),
|
||||
"activities": ("跨境活动", "家纺出海、平台招商、合规、知识产权与物流活动"),
|
||||
}
|
||||
|
||||
|
||||
def esc(value: Any) -> str:
|
||||
return html.escape(str(value or ""), quote=True)
|
||||
|
||||
|
||||
def absolute(site_url: str, path: str) -> str:
|
||||
return urljoin(site_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
|
||||
|
||||
def json_script(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
||||
|
||||
|
||||
def date_label(item: dict[str, Any]) -> str:
|
||||
start = str(item.get("start_date") or item.get("published_at") or "")[:10]
|
||||
end = str(item.get("end_date") or "")[:10]
|
||||
if start and end and end != start:
|
||||
return f"{start} 至 {end}"
|
||||
return start or "持续监测"
|
||||
|
||||
|
||||
def item_schema(item: dict[str, Any], canonical: str) -> dict[str, Any]:
|
||||
section = item.get("section")
|
||||
common = {
|
||||
"@context": "https://schema.org",
|
||||
"name": item.get("title"),
|
||||
"description": item.get("summary"),
|
||||
"url": canonical,
|
||||
"mainEntityOfPage": canonical,
|
||||
"inLanguage": "zh-CN",
|
||||
"keywords": keywords_for(str(section or ""), item),
|
||||
"isAccessibleForFree": True,
|
||||
}
|
||||
if section in ("calendar", "exhibition", "activity"):
|
||||
schema = {"@type": "Event", **common}
|
||||
schema["startDate"] = item.get("start_date")
|
||||
schema["endDate"] = item.get("end_date") or item.get("start_date")
|
||||
schema["eventStatus"] = "https://schema.org/EventScheduled"
|
||||
online = item.get("format") == "线上"
|
||||
schema["eventAttendanceMode"] = (
|
||||
"https://schema.org/OnlineEventAttendanceMode" if online
|
||||
else "https://schema.org/OfflineEventAttendanceMode"
|
||||
)
|
||||
schema["location"] = (
|
||||
{"@type": "VirtualLocation", "url": item.get("registration_url") or item.get("url")}
|
||||
if online else {
|
||||
"@type": "Place",
|
||||
"name": item.get("venue") or item.get("region") or "活动地点",
|
||||
"address": item.get("region") or "",
|
||||
}
|
||||
)
|
||||
schema["organizer"] = {"@type": "Organization", "name": item.get("source"), "url": item.get("source_url")}
|
||||
return schema
|
||||
return {
|
||||
"@type": "Article",
|
||||
**common,
|
||||
"headline": item.get("title"),
|
||||
"dateModified": item.get("verified_at"),
|
||||
"datePublished": item.get("published_at") or item.get("verified_at"),
|
||||
"author": {"@type": "Organization", "name": item.get("source")},
|
||||
"publisher": {"@type": "Organization", "name": "三笙AI情报局"},
|
||||
}
|
||||
|
||||
|
||||
def page_shell(*, title: str, description: str, canonical: str, body: str, schema: dict[str, Any], site_url: str, section: str = "") -> str:
|
||||
image = absolute(site_url, "/assets/logo.svg")
|
||||
keywords = ",".join(keywords_for(section))
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{esc(title)}</title>
|
||||
<meta name="description" content="{esc(description[:155])}">
|
||||
<meta name="robots" content="index,follow,max-image-preview:large">
|
||||
<meta name="author" content="{esc(BRAND_NAME)}">
|
||||
<meta name="keywords" content="{esc(keywords)}">
|
||||
<meta name="ai-content-declaration" content="official-facts-separated-from-machine-translation-and-analysis">
|
||||
<link rel="canonical" href="{esc(canonical)}">
|
||||
<link rel="alternate" hreflang="zh-CN" href="{esc(canonical)}">
|
||||
<link rel="alternate" hreflang="x-default" href="{esc(canonical)}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:locale" content="zh_CN">
|
||||
<meta property="og:site_name" content="{esc(BRAND_NAME)}">
|
||||
<meta property="og:title" content="{esc(title)}">
|
||||
<meta property="og:description" content="{esc(description[:200])}">
|
||||
<meta property="og:url" content="{esc(canonical)}">
|
||||
<meta property="og:image" content="{esc(image)}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="stylesheet" href="/assets/css/seo-textile.css?v=2.0">
|
||||
<script type="application/ld+json">{json_script(schema)}</script>
|
||||
<script type="module" src="/assets/js/seo-visitor-region.js?v=20260814f"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="seo-topbar">
|
||||
<a class="seo-brand" href="/?intel=today">三笙AI情报局</a>
|
||||
<nav aria-label="家纺情报栏目">
|
||||
<a href="/textile/calendar/">备货日历</a>
|
||||
<a href="/textile/exhibitions/">展会通</a>
|
||||
<a href="/textile/policies/">政策预警</a>
|
||||
<a href="/textile/standards/">趋势雷达</a>
|
||||
<a href="/textile/platforms/">平台渠道</a>
|
||||
<a href="/textile/activities/">跨境活动</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="seo-shell">{body}</main>
|
||||
<footer class="seo-footer">官方事实、机器翻译与本站经营分析分区展示;业务决策前请打开官方来源复核适用范围与生效日期。</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def tags_html(values: list[str] | tuple[str, ...] | None) -> str:
|
||||
return "".join(f'<span>{esc(value)}</span>' for value in (values or []) if value)
|
||||
|
||||
|
||||
TRANSLATED_LINK_LABEL = "查看译文版"
|
||||
|
||||
|
||||
def links_html(links: list[tuple[str, str]], class_name: str = "") -> str:
|
||||
seen: set[str] = set()
|
||||
output: list[str] = []
|
||||
translation_group = 0
|
||||
for label, url in links:
|
||||
if not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
provider = "yandex" if "translate.yandex.com/" in url else ("google" if "translate.google.com/" in url else "")
|
||||
if provider:
|
||||
label = TRANSLATED_LINK_LABEL
|
||||
else:
|
||||
translation_group += 1
|
||||
translation_gate = f' data-translation-provider="{provider}" data-translation-group="{translation_group}" hidden' if provider else ""
|
||||
output.append(
|
||||
f'<a class="{esc(class_name)}" href="{esc(url)}" target="_blank"{translation_gate} '
|
||||
f'rel="noopener noreferrer nofollow">{esc(label)}</a>'
|
||||
)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def load_trend_items(data_path: Path) -> list[dict[str, Any]]:
|
||||
trend_path = data_path.with_name("textile-trends.json")
|
||||
if not trend_path.exists():
|
||||
return []
|
||||
payload = json.loads(trend_path.read_text(encoding="utf-8"))
|
||||
verified_at = str(payload.get("generated_at") or "")
|
||||
items: list[dict[str, Any]] = []
|
||||
for category in (payload.get("categories") or {}).values():
|
||||
for raw in category.get("items") or []:
|
||||
item = dict(raw)
|
||||
item.update({
|
||||
"id": f"trend-{raw['id']}",
|
||||
"section": "trend",
|
||||
"image": raw.get("thumbnail") or "",
|
||||
"source_url": raw.get("url") or "",
|
||||
"authority": "行业媒体或博物馆原始来源",
|
||||
"confidence": "参考",
|
||||
"verified_at": verified_at,
|
||||
"region": "全球",
|
||||
"trend_kind": "news",
|
||||
})
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def card_html(item: dict[str, Any], detail_path: str, section: str) -> str:
|
||||
accent = esc((item.get("poster") or {}).get("accent") or "#ff6b35")
|
||||
meta = " · ".join(filter(None, (date_label(item), str(item.get("region") or ""), str(item.get("source") or ""))))
|
||||
culture = esc(item.get("culture_key") or "global")
|
||||
extra = ""
|
||||
if section == "calendar":
|
||||
extra = f"""<p class="seo-decision">{esc(item.get('decision_label') or '经营观察')} · {esc(item.get('planning_stage'))}</p>
|
||||
<p class="seo-window">备货窗口 {esc(item.get('stocking_start'))} 至 {esc(item.get('stocking_end'))}</p>
|
||||
<div class="seo-tags">{tags_html(item.get('affected_countries'))}{tags_html(item.get('product_suggestions'))}</div>"""
|
||||
elif section == "policies":
|
||||
extra = f'<div class="seo-tags">{tags_html(item.get("affected_markets"))}{tags_html(item.get("platforms"))}</div>'
|
||||
elif section == "platforms":
|
||||
extra = f'<p class="seo-decision">{esc(item.get("access_level"))} {esc(item.get("access_label"))} · {esc(item.get("confidence"))}</p><div class="seo-tags">{tags_html(item.get("markets"))}{tags_html(item.get("fit_sellers"))}</div>'
|
||||
elif section == "activities":
|
||||
extra = f'<p class="seo-decision">{esc(item.get("source_level"))} · {esc(item.get("activity_type"))} · {esc(item.get("format"))}</p><div class="seo-tags">{tags_html(item.get("platforms"))}</div>'
|
||||
return f"""<article class="seo-card is-{esc(section)} culture-{culture}" style="--card-accent:{accent}">
|
||||
<p class="seo-card-meta">{esc(meta)}</p>
|
||||
<h2><a href="/{esc(detail_path)}">{esc(item.get('title'))}</a></h2>
|
||||
<p>{esc(item.get('summary'))}</p>
|
||||
{extra}
|
||||
<span class="seo-proof">{esc(item.get('authority'))} · {esc(item.get('confidence'))}可信度</span>
|
||||
</article>"""
|
||||
|
||||
|
||||
def build_collection(section: str, items: list[dict[str, Any]], site_url: str) -> tuple[str, list[tuple[str, str]]]:
|
||||
title, description = SECTION_CONFIG[section]
|
||||
selected = items
|
||||
detail_entries: list[tuple[str, str]] = []
|
||||
cards: list[str] = []
|
||||
for item in selected:
|
||||
detail_path = f"textile/intelligence/{section}/{item['id']}/index.html"
|
||||
cards.append(card_html(item, detail_path.removesuffix("index.html"), section))
|
||||
detail_entries.append((detail_path, item["id"]))
|
||||
canonical = absolute(site_url, f"/textile/{section}/")
|
||||
item_list = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
"name": f"家纺跨境情报 · {title}",
|
||||
"description": description,
|
||||
"url": canonical,
|
||||
"mainEntity": {
|
||||
"@type": "ItemList",
|
||||
"numberOfItems": len(selected),
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": index + 1, "name": item.get("title"), "url": absolute(site_url, f"/{path.removesuffix('index.html')}")}
|
||||
for index, ((path, _item_id), item) in enumerate(zip(detail_entries, selected))
|
||||
],
|
||||
},
|
||||
}
|
||||
body = f"""<section class="seo-hero">
|
||||
<p class="seo-eyebrow">每日更新 · 权威源验证</p>
|
||||
<h1>{esc(title)}</h1>
|
||||
<p>{esc(description)}。当前静态收录 {len(selected)} 条,可直接被搜索引擎读取。</p>
|
||||
</section>
|
||||
<section class="seo-answer" data-answer-first="true"><h2>快速答案</h2><p>{esc(answer_first(section))}</p></section>
|
||||
<section class="seo-grid">{''.join(cards)}</section>"""
|
||||
return page_shell(
|
||||
title=f"{title}|家纺跨境情报|每日机会与风险提醒",
|
||||
description=description,
|
||||
canonical=canonical,
|
||||
body=body,
|
||||
schema=item_list,
|
||||
site_url=site_url,
|
||||
section=section,
|
||||
), detail_entries
|
||||
|
||||
|
||||
def build_detail(item: dict[str, Any], section: str, site_url: str) -> str:
|
||||
section_title = SECTION_CONFIG[section][0]
|
||||
canonical = absolute(site_url, f"/textile/intelligence/{section}/{item['id']}/")
|
||||
action = item.get("opportunity") or item.get("action") or item.get("application") or "打开官方来源复核后纳入选品、备货与合规流程。"
|
||||
image = item.get("image")
|
||||
visual = (
|
||||
f'<img class="seo-detail-image" src="{esc(image)}" alt="{esc(item.get("title"))}" loading="eager">'
|
||||
if image
|
||||
else f'<div class="seo-poster" style="--poster-accent:{esc((item.get("poster") or {}).get("accent"))}"><span>{esc(section_title)}</span><strong>{esc(item.get("title"))}</strong><small>{esc(item.get("region"))}</small></div>'
|
||||
)
|
||||
section_details = ""
|
||||
original_title = str(item.get("title_original") or "")
|
||||
original_html = (
|
||||
f'<p class="seo-original" lang="en">{esc(original_title)}</p>'
|
||||
if original_title and original_title != str(item.get("title") or "")
|
||||
else ""
|
||||
)
|
||||
if section == "calendar":
|
||||
scope_lines = [
|
||||
f"{detail.get('country')}:{detail.get('scope') or '全国适用'}"
|
||||
for detail in item.get("country_details") or []
|
||||
]
|
||||
official_links = links_html([
|
||||
(f"{source.get('name') or '官方日期来源'}", str(source.get("url") or ""))
|
||||
for source in item.get("official_sources") or []
|
||||
])
|
||||
platform_rows = "".join(
|
||||
f"<li><strong>{esc(row.get('platform'))}</strong>:{esc(row.get('action'))}</li>"
|
||||
for row in item.get("platform_actions") or []
|
||||
)
|
||||
section_details = f"""<section class="seo-context">
|
||||
<p class="seo-decision">{esc(item.get('decision_label') or '经营观察')} · {esc(item.get('planning_stage'))}</p>
|
||||
<h2>这个节日是什么</h2>
|
||||
<p>{esc(item.get('what_it_is') or item.get('summary'))}</p>
|
||||
<div class="seo-context-grid">
|
||||
<div><strong>重点国家/地区</strong><div class="seo-tags">{tags_html(item.get('affected_countries'))}</div></div>
|
||||
<div><strong>法定适用范围</strong><p>{esc(';'.join(scope_lines) or '以官方来源公布范围为准')}</p></div>
|
||||
<div><strong>主要语种</strong><div class="seo-tags">{tags_html(item.get('languages'))}</div></div>
|
||||
<div><strong>相关平台</strong><div class="seo-tags">{tags_html(item.get('platforms'))}</div></div>
|
||||
<div><strong>消费场景</strong><div class="seo-tags">{tags_html(item.get('consumption_scenes'))}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="seo-context">
|
||||
<h2>家纺备货与设计机会</h2>
|
||||
<p><strong>{esc(item.get('stocking_start'))} 至 {esc(item.get('stocking_end'))}</strong> · {esc(item.get('next_action') or item.get('opportunity'))}</p>
|
||||
<div class="seo-tags">{tags_html(item.get('product_suggestions'))}</div>
|
||||
<p><strong>设计方向:</strong>{esc(';'.join(item.get('design_opportunities') or []))}</p>
|
||||
<p class="seo-caution"><strong>文化与表达注意:</strong>{esc(';'.join(item.get('cultural_avoidances') or []))}</p>
|
||||
{f'<h2>平台经营动作</h2><ul>{platform_rows}</ul>' if platform_rows else ''}
|
||||
</section>
|
||||
<section class="seo-context seo-proof-layer">
|
||||
<h2>事实与分析边界</h2>
|
||||
<p>官方事实:日期、适用国家和节日名称由来源核验。系统分析:消费场景、产品方向、设计建议和备货节奏属于经营辅助判断。</p>
|
||||
<div class="seo-link-row">{official_links}</div>
|
||||
</section>"""
|
||||
elif section == "exhibitions":
|
||||
exhibition_links = links_html([
|
||||
(str(item.get("registration_mode") or "参展信息"), str(item.get("registration_url") or "")),
|
||||
("中文查看报名说明", str(item.get("registration_translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("registration_translation_fallback_url") or "")),
|
||||
(str(item.get("visitor_mode") or "观展信息"), str(item.get("visitor_url") or "")),
|
||||
("中文查看观众登记", str(item.get("visitor_translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("visitor_translation_fallback_url") or "")),
|
||||
("官方展会原文", str(item.get("url") or item.get("source_url") or "")),
|
||||
("官网简体中文阅读", str(item.get("translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("translation_fallback_url") or "")),
|
||||
])
|
||||
section_details = f"""<section class="seo-context">
|
||||
<h2>{esc(item.get('attendance_recommendation') or '先评估是否值得参加')}</h2>
|
||||
<p>{esc(item.get('attendance_reason') or item.get('opportunity'))}</p>
|
||||
<div class="seo-tags">{tags_html(item.get('event_focus'))}</div>
|
||||
<ol>{''.join(f'<li>{esc(value)}</li>' for value in item.get('trip_checklist') or [])}</ol>
|
||||
<p class="seo-caution">机器翻译仅用于辅助阅读;报名、验证码、付款和资料提交请回到官方域名完成。</p>
|
||||
<div class="seo-link-row">{exhibition_links}</div>
|
||||
</section>"""
|
||||
elif section == "policies":
|
||||
policy_links = links_html([
|
||||
("官方政策原文", str(item.get("url") or item.get("source_url") or "")),
|
||||
("官方来源简体中文阅读", str(item.get("translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("translation_fallback_url") or "")),
|
||||
])
|
||||
section_details = f"""<section class="seo-context">
|
||||
<h2>发生了什么</h2>
|
||||
<p>{esc(item.get('change_summary') or item.get('summary'))}</p>
|
||||
<p><strong>不处理的后果:</strong>{esc(item.get('consequence'))}</p>
|
||||
<p>{esc(item.get('policy_type'))} · {esc(item.get('change_status'))} · 生效日期 {esc(item.get('effective_at') or '以官方原文为准')}</p>
|
||||
<h2>适用市场与平台</h2>
|
||||
<div class="seo-tags">{tags_html(item.get('affected_markets'))}{tags_html(item.get('platforms'))}</div>
|
||||
<div class="seo-link-row">{policy_links}</div>
|
||||
</section>"""
|
||||
elif section == "platforms":
|
||||
platform_links = links_html([
|
||||
("查看家纺类目", str(item.get("official_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("official_translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("official_translation_fallback_url") or "")),
|
||||
("打开平台官网", str(item.get("homepage_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("homepage_translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("homepage_translation_fallback_url") or "")),
|
||||
("去官方入驻", str(item.get("seller_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("seller_translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("seller_translation_fallback_url") or "")),
|
||||
])
|
||||
section_details = f"""<section class="seo-context">
|
||||
<h2>家纺类目与入驻判断</h2>
|
||||
<p><strong>{esc(item.get('access_level'))} {esc(item.get('access_label'))}</strong> · {esc(item.get('cross_border'))}</p>
|
||||
<p>{esc(item.get('textile_category'))}</p>
|
||||
<div class="seo-tags">{tags_html(item.get('markets'))}{tags_html(item.get('fit_sellers'))}</div>
|
||||
<div class="seo-link-row">{platform_links}</div>
|
||||
</section>"""
|
||||
elif section == "activities":
|
||||
activity_links = links_html([
|
||||
("查看并报名", str(item.get("registration_url") or item.get("url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("translation_fallback_url") or "")),
|
||||
("查看来源页", str(item.get("source_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("source_translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("source_translation_fallback_url") or "")),
|
||||
])
|
||||
section_details = f"""<section class="seo-context">
|
||||
<h2>活动价值与参加方式</h2>
|
||||
<p><strong>{esc(item.get('source_level'))} · {esc(item.get('activity_type'))}</strong> · {esc(item.get('format'))} · {esc(item.get('location'))}</p>
|
||||
<p>{esc(item.get('next_action'))}</p>
|
||||
<div class="seo-tags">{tags_html(item.get('platforms'))}</div>
|
||||
<p class="seo-caution">行业媒体只用于发现活动;日期、议程、费用和报名状态以主办方或平台官方页面为准。机器翻译仅辅助阅读,正式提交请回原网站。</p>
|
||||
<div class="seo-link-row">{activity_links}</div>
|
||||
</section>"""
|
||||
elif section == "standards":
|
||||
trend_links = links_html([
|
||||
("查看原文", str(item.get("url") or item.get("source_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("translation_url") or "")),
|
||||
(TRANSLATED_LINK_LABEL, str(item.get("translation_fallback_url") or "")),
|
||||
])
|
||||
if item.get("trend_kind") == "news":
|
||||
section_details = f"""<section class="seo-context">
|
||||
<h2>这条趋势如何使用</h2>
|
||||
<p>该内容属于行业新闻或设计灵感参考,可用于选色、选材、图案和市场判断;打样与合规决策仍需结合产品规格及官方标准。</p>
|
||||
<p><strong>内容类别:</strong>{esc(item.get('category') or '趋势新闻')}</p>
|
||||
<div class="seo-link-row">{trend_links}</div>
|
||||
</section>"""
|
||||
else:
|
||||
tasks = "".join(f"<li>{esc(value)}</li>" for value in item.get("brief_items") or [])
|
||||
section_details = f"""<section class="seo-context">
|
||||
<h2>产品开发与检测动作</h2>
|
||||
<p>{esc(item.get('application') or item.get('decision_note') or item.get('summary'))}</p>
|
||||
{f'<ol>{tasks}</ol>' if tasks else ''}
|
||||
<div class="seo-link-row">{trend_links}</div>
|
||||
</section>"""
|
||||
body = f"""<article class="seo-detail">
|
||||
<nav class="seo-breadcrumb" aria-label="面包屑"><a href="/textile/intelligence/">家纺跨境情报</a><span>/</span><a href="/textile/{esc(section)}/">{esc(section_title)}</a></nav>
|
||||
<section class="seo-answer" data-answer-first="true"><h2>先看结论</h2><p>{esc(answer_first(section, item))}</p></section>
|
||||
<p class="seo-machine-translation-note">机器翻译仅用于辅助阅读;报名、付款、合规提交和日期确认以官方原文为准。</p>
|
||||
<p class="seo-eyebrow">{esc(item.get('authority'))} · {esc(item.get('confidence'))}可信度</p>
|
||||
<h1>{esc(item.get('title'))}</h1>
|
||||
{original_html}
|
||||
<p class="seo-lead">{esc(item.get('summary'))}</p>
|
||||
{visual}
|
||||
<dl class="seo-facts">
|
||||
<div><dt>时间</dt><dd>{esc(date_label(item))}</dd></div>
|
||||
<div><dt>区域</dt><dd>{esc(item.get('region'))}</dd></div>
|
||||
<div><dt>来源</dt><dd>{esc(item.get('source'))}</dd></div>
|
||||
<div><dt>核验</dt><dd>{esc(str(item.get('verified_at') or '')[:19].replace('T', ' '))}</dd></div>
|
||||
</dl>
|
||||
{section_details}
|
||||
<section class="seo-action"><h2>家纺行动建议</h2><p>{esc(action)}</p></section>
|
||||
<a class="seo-source-link" href="{esc(item.get('url'))}" target="_blank" rel="noopener noreferrer nofollow">打开官方来源</a>
|
||||
</article>"""
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
item_schema(item, canonical),
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": 1, "name": "家纺跨境情报", "item": absolute(site_url, "/textile/intelligence/")},
|
||||
{"@type": "ListItem", "position": 2, "name": section_title, "item": absolute(site_url, f"/textile/{section}/")},
|
||||
{"@type": "ListItem", "position": 3, "name": item.get("title"), "item": canonical},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return page_shell(
|
||||
title=f"{item.get('title')}|{section_title}|家纺跨境情报",
|
||||
description=str(item.get("summary") or ""),
|
||||
canonical=canonical,
|
||||
body=body,
|
||||
schema=schema,
|
||||
site_url=site_url,
|
||||
section=section,
|
||||
)
|
||||
|
||||
|
||||
def build_calendar_year(year: int, items: list[dict[str, Any]], site_url: str) -> str:
|
||||
canonical = absolute(site_url, f"/textile/calendar/{year}/")
|
||||
selected = sorted(items, key=lambda item: (str(item.get("start_date") or ""), -int(item.get("priority_score") or 0)))
|
||||
cards = "".join(card_html(item, f"textile/intelligence/calendar/{item['id']}/", "calendar") for item in selected)
|
||||
markets = len({country for item in selected for country in item.get("affected_countries") or []})
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
"name": f"{year} 全球家纺市场备货决策日历",
|
||||
"description": f"{year} 年全球节日、购物季、体育事件与家纺备货机会,覆盖 {markets} 个国家和地区。",
|
||||
"url": canonical,
|
||||
"mainEntity": {
|
||||
"@type": "ItemList",
|
||||
"numberOfItems": len(selected),
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": index + 1,
|
||||
"name": item.get("title"),
|
||||
"url": absolute(site_url, f"/textile/intelligence/calendar/{item['id']}/"),
|
||||
}
|
||||
for index, item in enumerate(selected)
|
||||
],
|
||||
},
|
||||
}
|
||||
body = f"""<section class="seo-hero">
|
||||
<p class="seo-eyebrow">{markets} 个国家和地区 · {len(selected)} 个精准机会</p>
|
||||
<h1>{year} 全球家纺市场备货决策日历</h1>
|
||||
<p>同一节日跨国聚合,按国家、语种、平台与消费场景倒排选品、设计、备货、入仓和营销窗口,并明确区分官方事实与经营分析。</p>
|
||||
</section>
|
||||
<nav class="seo-year-nav" aria-label="备货日历年份">
|
||||
{''.join(f'<a href="/textile/calendar/{year_value}/">{year_value}</a>' for year_value in sorted({int(str(item.get("start_date") or "0")[:4]) for item in selected if str(item.get("start_date") or "")[:4].isdigit()}))}
|
||||
</nav>
|
||||
<section class="seo-grid">{cards}</section>"""
|
||||
return page_shell(
|
||||
title=f"{year} 全球家纺市场备货决策日历|家纺跨境情报",
|
||||
description=f"{year} 年全球家纺备货决策日历,覆盖 {markets} 个国家和地区、{len(selected)} 个精准机会,包含文化解释、语种平台、产品设计建议和权威来源。",
|
||||
canonical=canonical,
|
||||
body=body,
|
||||
schema=schema,
|
||||
site_url=site_url,
|
||||
section="calendar",
|
||||
)
|
||||
|
||||
|
||||
def write_text(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="生成家纺情报静态 SEO 页面")
|
||||
parser.add_argument("--data", default="data/textile-intelligence.json", help="情报 JSON")
|
||||
parser.add_argument("--root", default=".", help="站点根目录")
|
||||
parser.add_argument("--site-url", default=DEFAULT_SITE_URL, help="生产站点绝对地址")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root).resolve()
|
||||
data_path = Path(args.data)
|
||||
payload = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
site_url = args.site_url.rstrip("/")
|
||||
trend_items = sorted(load_trend_items(data_path), key=lambda item: str(item.get("published_at") or ""), reverse=True)
|
||||
section_items = {
|
||||
"calendar": list(payload.get("calendar_insights") or payload.get("calendar") or []),
|
||||
"exhibitions": list(payload.get("exhibitions") or []),
|
||||
"policies": list(payload.get("policies") or []),
|
||||
"standards": [*(payload.get("standards") or []), *trend_items],
|
||||
"platforms": list(payload.get("platforms") or []),
|
||||
"activities": list(payload.get("activities") or []),
|
||||
}
|
||||
for section, items in section_items.items():
|
||||
if section != "standards":
|
||||
items.sort(key=lambda item: (str(item.get("start_date") or "9999"), str(item.get("title") or "")))
|
||||
|
||||
sitemap_paths = ["textile/intelligence/"]
|
||||
generated_detail_root = root / "textile" / "intelligence"
|
||||
for section in SECTION_CONFIG:
|
||||
target = (generated_detail_root / section).resolve()
|
||||
if target.parent == generated_detail_root.resolve() and target.exists():
|
||||
rmtree(target)
|
||||
all_items_by_id = {
|
||||
str(item["id"]): item
|
||||
for items in section_items.values()
|
||||
for item in items
|
||||
if item.get("id")
|
||||
}
|
||||
detail_jobs: list[tuple[str, str, str]] = []
|
||||
collection_links: list[str] = []
|
||||
for section, items in section_items.items():
|
||||
page, entries = build_collection(section, items, site_url)
|
||||
write_text(root / "textile" / section / "index.html", page)
|
||||
sitemap_paths.append(f"textile/{section}/")
|
||||
collection_links.append(f'<a href="/textile/{section}/"><strong>{esc(SECTION_CONFIG[section][0])}</strong><span>{len(items)} 条数据</span></a>')
|
||||
for item in items:
|
||||
detail_jobs.append((section, f"textile/intelligence/{section}/{item['id']}/index.html", item["id"]))
|
||||
|
||||
calendar_by_year: dict[int, list[dict[str, Any]]] = {}
|
||||
for item in section_items["calendar"]:
|
||||
year = int(str(item.get("start_date") or "0")[:4] or 0)
|
||||
if year:
|
||||
calendar_by_year.setdefault(year, []).append(item)
|
||||
for year, items in sorted(calendar_by_year.items()):
|
||||
write_text(root / "textile" / "calendar" / str(year) / "index.html", build_calendar_year(year, items, site_url))
|
||||
sitemap_paths.append(f"textile/calendar/{year}/")
|
||||
|
||||
overview_canonical = absolute(site_url, "/textile/intelligence/")
|
||||
overview_schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
"name": "家纺跨境情报 · 每日机会与风险提醒",
|
||||
"description": "每日更新全球备货日历、家纺展会、跨境活动、平台渠道、跨境政策、知识产权和纺织趋势标准。",
|
||||
"url": overview_canonical,
|
||||
}
|
||||
overview_body = f"""<section class="seo-hero">
|
||||
<p class="seo-eyebrow">{esc(payload.get('source_success'))} 个新增权威源 · 总体 {esc(payload.get('total_source_baseline'))} 源基线</p>
|
||||
<h1>家纺跨境情报</h1>
|
||||
<p>每日更新全球备货日历、家纺与纺织展会、跨境活动、平台渠道、跨境政策、知识产权和趋势标准,所有发布数据均保留来源等级与核验时间。</p>
|
||||
</section><section class="seo-section-links">{''.join(collection_links)}</section>"""
|
||||
write_text(
|
||||
root / "textile" / "intelligence" / "index.html",
|
||||
page_shell(
|
||||
title="家纺跨境情报|每日机会、风险与产品趋势",
|
||||
description="每日更新全球备货日历、家纺展会、跨境活动、平台渠道、跨境政策、知识产权和纺织趋势标准。",
|
||||
canonical=overview_canonical,
|
||||
body=overview_body,
|
||||
schema=overview_schema,
|
||||
site_url=site_url,
|
||||
),
|
||||
)
|
||||
|
||||
for section, path, item_id in detail_jobs:
|
||||
item = all_items_by_id[item_id]
|
||||
write_text(root / path, build_detail(item, section, site_url))
|
||||
sitemap_paths.append(path.removesuffix("index.html"))
|
||||
|
||||
modified = datetime.now(tz=timezone.utc).date().isoformat()
|
||||
urls = "".join(
|
||||
f" <url><loc>{esc(absolute(site_url, path))}</loc><lastmod>{modified}</lastmod></url>\n"
|
||||
for path in sitemap_paths
|
||||
)
|
||||
sitemap = f'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n{urls}</urlset>\n'
|
||||
write_text(root / "sitemap.xml", sitemap)
|
||||
write_text(root / "robots.txt", f"User-agent: *\nAllow: /\nSitemap: {absolute(site_url, '/sitemap.xml')}\n")
|
||||
print(f"静态 SEO 生成完成:入口 {len(SECTION_CONFIG) + 1} 个,详情 {len(detail_jobs)} 个,Sitemap URL {len(sitemap_paths)} 个")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""构建可直接部署或由本地 HTTP 服务打开的独立静态站。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from seo_config import BRAND_NAME, ENTITY_DEFINITION, KEYWORD_CLUSTERS, SECTION_INTENTS
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PUBLISH_DATA = ("textile-intelligence.json", "textile-trends.json")
|
||||
DEFAULT_SITE_URL = "http://127.0.0.1:8765"
|
||||
|
||||
|
||||
def replace_directory(source: Path, target: Path) -> None:
|
||||
previous = target.with_name(f".{target.name}-previous")
|
||||
if previous.exists():
|
||||
shutil.rmtree(previous)
|
||||
if target.exists():
|
||||
target.replace(previous)
|
||||
try:
|
||||
source.replace(target)
|
||||
except Exception:
|
||||
if previous.exists() and not target.exists():
|
||||
previous.replace(target)
|
||||
raise
|
||||
if previous.exists():
|
||||
shutil.rmtree(previous)
|
||||
|
||||
|
||||
def write_llms_file(target: Path, payload: dict, site_url: str) -> None:
|
||||
summary = payload.get("summary") or {}
|
||||
lines = [
|
||||
f"# {BRAND_NAME}",
|
||||
"",
|
||||
f"> {ENTITY_DEFINITION}",
|
||||
"",
|
||||
f"- 站点:{site_url}/",
|
||||
f"- 权威源:{payload.get('source_success', 0)}/{payload.get('source_target', 0)}",
|
||||
f"- 备货机会:{summary.get('calendar_featured', 0)}",
|
||||
f"- 展会:{summary.get('exhibitions', 0)}",
|
||||
f"- 政策:{summary.get('policies', 0)}",
|
||||
f"- 平台:{summary.get('platforms', 0)}",
|
||||
f"- 跨境活动:{summary.get('activities', 0)}",
|
||||
f"- 核心词:{'、'.join((*KEYWORD_CLUSTERS['core'], *KEYWORD_CLUSTERS['task']))}",
|
||||
"",
|
||||
"## 可索引栏目",
|
||||
"",
|
||||
f"- [备货日历]({site_url}/textile/calendar/)",
|
||||
f"- [展会通]({site_url}/textile/exhibitions/)",
|
||||
f"- [政策预警]({site_url}/textile/policies/)",
|
||||
f"- [趋势雷达]({site_url}/textile/standards/)",
|
||||
f"- [平台渠道]({site_url}/textile/platforms/)",
|
||||
f"- [跨境活动]({site_url}/textile/activities/)",
|
||||
"",
|
||||
"## 栏目回答意图",
|
||||
"",
|
||||
*[f"- {section}:{intent}" for section, intent in SECTION_INTENTS.items()],
|
||||
"",
|
||||
"官方事实、机器翻译与本站经营分析分区展示;机器翻译仅作阅读辅助,不替代报名、付款或合规提交。",
|
||||
]
|
||||
(target / "llms.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def build(data_dir: Path, output_dir: Path, site_url: str) -> None:
|
||||
data_dir = data_dir.resolve()
|
||||
output_dir = output_dir.resolve()
|
||||
staging = output_dir.with_name(f".{output_dir.name}-build")
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
shutil.copytree(ROOT / "public", staging)
|
||||
target_data = staging / "data"
|
||||
target_data.mkdir(parents=True)
|
||||
for name in PUBLISH_DATA:
|
||||
source = data_dir / name
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"缺少发布数据:{source}")
|
||||
shutil.copy2(source, target_data / name)
|
||||
|
||||
index = staging / "index.html"
|
||||
html = index.read_text(encoding="utf-8")
|
||||
html = html.replace('<link rel="canonical" href="./" />', f'<link rel="canonical" href="{site_url}/" />')
|
||||
html = html.replace('<link rel="alternate" hreflang="zh-CN" href="./" />', f'<link rel="alternate" hreflang="zh-CN" href="{site_url}/" />')
|
||||
html = html.replace('<link rel="alternate" hreflang="x-default" href="./" />', f'<link rel="alternate" hreflang="x-default" href="{site_url}/" />')
|
||||
html = html.replace('<meta property="og:image" content="./assets/logo.svg" />', f'<meta property="og:image" content="{site_url}/assets/logo.svg" />')
|
||||
index.write_text(html, encoding="utf-8", newline="\n")
|
||||
|
||||
subprocess.run([
|
||||
sys.executable,
|
||||
str(ROOT / "scripts" / "build_seo.py"),
|
||||
"--data", str(target_data / "textile-intelligence.json"),
|
||||
"--root", str(staging),
|
||||
"--site-url", site_url,
|
||||
], check=True, cwd=ROOT)
|
||||
payload = json.loads((target_data / "textile-intelligence.json").read_text(encoding="utf-8"))
|
||||
write_llms_file(staging, payload, site_url)
|
||||
required = ("index.html", "robots.txt", "sitemap.xml", "llms.txt")
|
||||
missing = [name for name in required if not (staging / name).is_file()]
|
||||
if missing:
|
||||
raise RuntimeError(f"构建产物不完整:{missing}")
|
||||
replace_directory(staging, output_dir)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="构建三笙AI情报局独立静态站")
|
||||
parser.add_argument("--data-dir", default=str(ROOT / "data"))
|
||||
parser.add_argument("--output-dir", default=str(ROOT / "dist"))
|
||||
parser.add_argument("--site-url", default=os.environ.get("SANSHENG_SITE_URL", DEFAULT_SITE_URL))
|
||||
args = parser.parse_args()
|
||||
site_url = args.site_url.rstrip("/")
|
||||
try:
|
||||
build(Path(args.data_dir), Path(args.output_dir), site_url)
|
||||
except Exception as exc:
|
||||
print(f"[构建失败] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"[构建完成] {Path(args.output_dir).resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,628 @@
|
||||
#!/usr/bin/env python3
|
||||
"""抓取与家纺出海直接相关或跨境经营必须关注的活动。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
try:
|
||||
from .intelligence_web_translation import translation_fields
|
||||
except ImportError:
|
||||
from intelligence_web_translation import translation_fields
|
||||
|
||||
|
||||
ACTIVITY_SOURCES: tuple[dict[str, str], ...] = (
|
||||
{
|
||||
"id": "activity_cifnews",
|
||||
"name": "雨果跨境活动",
|
||||
"url": "https://www.cifnews.com/activity",
|
||||
"authority": "行业媒体发现",
|
||||
"level": "行业媒体",
|
||||
},
|
||||
{
|
||||
"id": "activity_amazon",
|
||||
"name": "Amazon 全球开店官方直播",
|
||||
"url": "https://gs.amazon.cn/resources/live-broadcast",
|
||||
"authority": "平台官方",
|
||||
"level": "官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_wipo",
|
||||
"name": "WIPO 官方 Webinar",
|
||||
"url": "https://www.wipo.int/meetings/en/topic.jsp?group_id=7",
|
||||
"authority": "国际组织官方",
|
||||
"level": "官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_cbp",
|
||||
"name": "美国海关 CBP Trade Webinar",
|
||||
"url": "https://www.cbp.gov/trade/stakeholder-engagement/webinars",
|
||||
"authority": "美国政府官方",
|
||||
"level": "官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_ccpit",
|
||||
"name": "中国贸促会经贸活动云",
|
||||
"url": "https://bizevent.ccpit.org/",
|
||||
"authority": "中国贸促会官方",
|
||||
"level": "政府/贸促机构",
|
||||
},
|
||||
{
|
||||
"id": "activity_amazon_global",
|
||||
"name": "Amazon 全球卖家活动",
|
||||
"url": "https://sell.amazon.com/events",
|
||||
"authority": "Amazon 平台官方",
|
||||
"level": "官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_ebay",
|
||||
"name": "eBay Seller Events",
|
||||
"url": "https://community.ebay.com/ebay-seller-events/",
|
||||
"authority": "eBay 平台官方",
|
||||
"level": "官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_eu_ip",
|
||||
"name": "欧盟知识产权帮助中心活动",
|
||||
"url": "https://intellectual-property-helpdesk.ec.europa.eu/news-events/upcoming-trainings-events_en",
|
||||
"authority": "欧盟委员会官方",
|
||||
"level": "国际/区域官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_uspto",
|
||||
"name": "USPTO 商标与品牌保护活动",
|
||||
"url": "https://www.uspto.gov/about-us/events",
|
||||
"authority": "美国专利商标局官方",
|
||||
"level": "政府官方",
|
||||
},
|
||||
{
|
||||
"id": "activity_eu_trade",
|
||||
"name": "欧盟贸易政策活动",
|
||||
"url": "https://policy.trade.ec.europa.eu/events_en",
|
||||
"authority": "欧盟委员会贸易总司官方",
|
||||
"level": "国际/区域官方",
|
||||
},
|
||||
)
|
||||
|
||||
TEXTILE_TERMS = re.compile(
|
||||
r"家纺|家居|床垫|床品|床单|寝具|被子|被套|棉被|枕|靠垫|窗帘|毛巾|纺织|面料|Wayfair|"
|
||||
r"textile|bedding|mattress|cushion",
|
||||
re.I,
|
||||
)
|
||||
HOME_TEXTILE_TERMS = re.compile(
|
||||
r"家纺|家居纺织|床垫|床品|床单|寝具|被子|被套|棉被|枕头|枕芯|靠垫|窗帘|毛巾|浴巾|"
|
||||
r"home textile|bedding|mattress|pillow|cushion|duvet|bed linen|curtain|towel",
|
||||
re.I,
|
||||
)
|
||||
APPAREL_ONLY_TERMS = re.compile(r"女装|男装|西装|礼服|鞋服|美妆|women'?s suit|menswear|womenswear|apparel only", re.I)
|
||||
COMPLIANCE_TERMS = re.compile(
|
||||
r"合规|法规|税则|海关|关税|商标|知识产权|品牌保护|侵权|认证|EPR|VAT|GPSR|EAPA|INFORM|"
|
||||
r"trademark|Madrid System|classification|heading\s*\d|trade violations?",
|
||||
re.I,
|
||||
)
|
||||
LOGISTICS_TERMS = re.compile(r"物流|仓储|FBA|配送|退货|海外仓|供应链|履约", re.I)
|
||||
PLATFORM_ACTION_TERMS = re.compile(
|
||||
r"入驻|招商|平台规则|账户|账号|认证|INFORM|合规|费用|佣金|物流|仓储|FBA|退货|履约|关税|税务|召回|"
|
||||
r"onboarding|seller verification|account|compliance|fee|fulfillment|warehouse|returns?|tariff|recall",
|
||||
re.I,
|
||||
)
|
||||
PLATFORM_TERMS: tuple[tuple[str, str], ...] = (
|
||||
("Amazon", r"Amazon|亚马逊"),
|
||||
("Walmart", r"Walmart|沃尔玛"),
|
||||
("TikTok Shop", r"TikTok|TK\b"),
|
||||
("Temu", r"Temu"),
|
||||
("Shopee", r"Shopee"),
|
||||
("Lazada", r"Lazada"),
|
||||
("Mercado Libre", r"Mercado Libre|美客多"),
|
||||
("Ozon", r"Ozon"),
|
||||
("eBay", r"eBay"),
|
||||
("Noon", r"Noon"),
|
||||
("Wayfair", r"Wayfair"),
|
||||
("Rakuten", r"Rakuten|乐天"),
|
||||
("Coupang", r"Coupang"),
|
||||
("Jumia", r"Jumia"),
|
||||
)
|
||||
MONTHS = {name: index for index, name in enumerate(
|
||||
("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"),
|
||||
start=1,
|
||||
)}
|
||||
MONTHS.update({name[:3]: index for name, index in MONTHS.copy().items()})
|
||||
|
||||
|
||||
def _text(value: str) -> str:
|
||||
return " ".join((value or "").split())
|
||||
|
||||
|
||||
def _id(source_id: str, start_date: str, url: str) -> str:
|
||||
digest = hashlib.sha256(f"{source_id}|{start_date}|{url}".encode("utf-8")).hexdigest()[:18]
|
||||
return f"activity-{digest}"
|
||||
|
||||
|
||||
def _platforms(title: str) -> list[str]:
|
||||
return [name for name, pattern in PLATFORM_TERMS if re.search(pattern, title, re.I)]
|
||||
|
||||
|
||||
def _region(title: str, location: str, platforms: list[str]) -> str:
|
||||
value = f"{title} {location}"
|
||||
if re.search(r"欧洲|英国|德国|法国|EU\b|Europe", value, re.I):
|
||||
return "欧洲"
|
||||
if re.search(r"美国|加拿大|北美|US\b|USA|Canada", value, re.I):
|
||||
return "北美"
|
||||
if re.search(r"中东|沙特|阿联酋|Noon", value, re.I):
|
||||
return "中东"
|
||||
if re.search(r"日本|韩国|Rakuten|Coupang", value, re.I):
|
||||
return "东亚"
|
||||
if re.search(r"东南亚|Shopee|Lazada|TikTok", value, re.I):
|
||||
return "东南亚"
|
||||
if re.search(r"拉美|巴西|墨西哥|美客多|Mercado", value, re.I):
|
||||
return "拉丁美洲"
|
||||
if location and location not in ("线上", "Virtual"):
|
||||
return "中国"
|
||||
return "全球"
|
||||
|
||||
|
||||
def _profile(title: str, source_id: str) -> tuple[str, str, str]:
|
||||
if HOME_TEXTILE_TERMS.search(title) and not APPAREL_ONLY_TERMS.search(title):
|
||||
return (
|
||||
"家纺/纺织直接相关",
|
||||
"direct",
|
||||
"与家纺、纺织品或家居品类直接相关,优先核对产品规格、税则归类、平台机会与报名截止时间。",
|
||||
)
|
||||
if COMPLIANCE_TERMS.search(title):
|
||||
return (
|
||||
"跨境合规必看",
|
||||
"must",
|
||||
"涉及关税、海关、知识产权或平台法规,可能直接影响商品上架、清关、账户与品牌安全。",
|
||||
)
|
||||
if LOGISTICS_TERMS.search(title):
|
||||
return (
|
||||
"物流履约必看",
|
||||
"must",
|
||||
"涉及入仓、配送、退货或供应链成本,适合运营、跟单和物流负责人参加。",
|
||||
)
|
||||
if _platforms(title) and PLATFORM_ACTION_TERMS.search(title):
|
||||
return (
|
||||
"平台招商与运营",
|
||||
"must",
|
||||
"由平台或行业机构发布,适合核对最新招商条件、站点机会、旺季节奏和运营规则。",
|
||||
)
|
||||
if source_id in {"activity_ccpit", "activity_eu_trade"} and re.search(r"出海|跨境|贸易|关税|海关|Trade Policy|Export Control|digital trade|supply chain", title, re.I):
|
||||
return (
|
||||
"跨境经营必看",
|
||||
"must",
|
||||
"由政府或区域官方发布,适合核对贸易政策、出海服务、关税、供应链与市场准入变化。",
|
||||
)
|
||||
return ("经营增长", "observe", "属于一般跨境经营活动,家纺团队可按目标市场和当前问题决定是否参加。")
|
||||
|
||||
|
||||
def _localized_title(title: str) -> str:
|
||||
exact = {
|
||||
"Mattresses and Cushions: Hybrids and More of Heading 9404": "美国海关:床垫与靠垫混合产品(税则号 9404)归类讲座",
|
||||
"Wadding, Gauze, Bandages, and Similar Articles": "美国海关:絮胎、纱布、绷带及类似制品归类讲座",
|
||||
"Enforce and Protect Act (EAPA) Q3": "美国海关:EAPA 反逃避执法与举报机制讲座",
|
||||
"Madrid System Webinar: Managing International Trademark Registration – Subsequent Designation and Renewal (in Chinese)": "WIPO 中文讲座:国际商标注册的后期指定与续展管理",
|
||||
}
|
||||
return exact.get(title, title)
|
||||
|
||||
|
||||
def _item(
|
||||
*, source: dict[str, str], title: str, start_date: date, url: str, checked_at: str,
|
||||
location: str = "线上", image: str = "", source_language: str = "zh-CN", profile_text: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
title = _text(title)
|
||||
category, relevance, summary = _profile(_text(profile_text) or title, source["id"])
|
||||
if relevance == "observe":
|
||||
return None
|
||||
platforms = _platforms(title)
|
||||
region = _region(title, location, platforms)
|
||||
display_title = _localized_title(title)
|
||||
item = {
|
||||
"id": _id(source["id"], start_date.isoformat(), url),
|
||||
"section": "activity",
|
||||
"title": display_title,
|
||||
"title_original": title if display_title != title else "",
|
||||
"summary": summary,
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": start_date.isoformat(),
|
||||
"days_until": 0,
|
||||
"location": location or "线上",
|
||||
"venue": location or "线上",
|
||||
"format": "线上" if location in ("线上", "Virtual") else "线下",
|
||||
"region": region,
|
||||
"platforms": platforms,
|
||||
"activity_type": category,
|
||||
"relevance": relevance,
|
||||
"source": source["name"],
|
||||
"source_id": source["id"],
|
||||
"source_url": url,
|
||||
"url": url,
|
||||
"registration_url": url,
|
||||
"authority": source["authority"],
|
||||
"source_level": source["level"],
|
||||
"confidence": "中" if source["level"] == "行业媒体" else "高",
|
||||
"official_activity": source["level"] != "行业媒体",
|
||||
"image": image,
|
||||
"verified_at": checked_at,
|
||||
"checked_at": checked_at,
|
||||
"next_action": "先核对议程、适用站点和报名截止时间,再决定由老板、运营、合规或跟单人员参加。",
|
||||
"poster": {"eyebrow": "跨境活动", "accent": "#2f7d6d", "ink": "#10251f", "paper": "#eef8f4"},
|
||||
}
|
||||
translation = translation_fields(url, source_language, title)
|
||||
item.update(translation)
|
||||
item.update({
|
||||
"source_needs_translation": translation["needs_translation"],
|
||||
"source_translation_url": translation["translation_url"],
|
||||
"source_translation_fallback_url": translation["translation_fallback_url"],
|
||||
})
|
||||
return item
|
||||
|
||||
|
||||
def _parse_cifnews(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for card in soup.select(".cif-flexbox__wrap"):
|
||||
title_node = card.select_one(".title-text")
|
||||
image_node = card.select_one(".cif-flexbox__imgbox img")
|
||||
title = _text(title_node.get_text(" ", strip=True) if title_node else "")
|
||||
image = str(image_node.get("src") or "") if image_node else ""
|
||||
date_node = card.select_one(".desc.time")
|
||||
if date_node:
|
||||
match = re.search(r"(20\d{2})[.-](\d{1,2})[.-](\d{1,2})", date_node.get_text(" ", strip=True))
|
||||
link = card.select_one(".signup-active-btn[href]") or card.select_one("a.title[href]")
|
||||
if not match or not link:
|
||||
continue
|
||||
start = date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||
if start < now:
|
||||
continue
|
||||
location_node = card.select_one(".bottom .desc")
|
||||
item = _item(
|
||||
source=source, title=title, start_date=start,
|
||||
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
|
||||
location=_text(location_node.get_text(" ", strip=True) if location_node else "线下"),
|
||||
image=image,
|
||||
)
|
||||
if item:
|
||||
rows.append(item)
|
||||
continue
|
||||
for event in card.select(".series-item.is-active[href]"):
|
||||
match = re.search(r"(\d{1,2})[.-](\d{1,2})", _text(event.get_text(" ", strip=True)))
|
||||
if not match:
|
||||
continue
|
||||
start = date(now.year, int(match.group(1)), int(match.group(2)))
|
||||
if start < now:
|
||||
continue
|
||||
city_node = event.select_one(".series-item-city")
|
||||
city = _text(city_node.get_text(" ", strip=True) if city_node else "线下")
|
||||
item = _item(
|
||||
source=source, title=f"{title} · {city}", start_date=start,
|
||||
url=urljoin(source["url"], str(event.get("href") or "")), checked_at=checked_at,
|
||||
location=city, image=image,
|
||||
)
|
||||
if item:
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_amazon(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for card in soup.select(".tile-wrap"):
|
||||
value = _text(card.get_text(" ", strip=True))
|
||||
match = re.search(r"(20\d{2}-\d{2}-\d{2})", value)
|
||||
link = card.select_one("a[href]")
|
||||
if not match or not link:
|
||||
continue
|
||||
start = date.fromisoformat(match.group(1))
|
||||
if start < now:
|
||||
continue
|
||||
title = value[:match.start()].strip(" -|:")
|
||||
item = _item(
|
||||
source=source, title=title, start_date=start,
|
||||
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
|
||||
location="线上", source_language="zh-CN",
|
||||
)
|
||||
if item:
|
||||
item["platforms"] = ["Amazon"]
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _english_date(value: str) -> date | None:
|
||||
match = re.search(
|
||||
r"(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s+(20\d{2})",
|
||||
value,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return date(int(match.group(3)), MONTHS[match.group(1)], int(match.group(2)))
|
||||
|
||||
|
||||
def _parse_wipo(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for card in soup.select("article.listing--event"):
|
||||
link = card.select_one("h3 a[href]")
|
||||
if not link:
|
||||
continue
|
||||
title = _text(link.get_text(" ", strip=True))
|
||||
if not re.search(r"Madrid System|Trademark|Industrial Design|Hague System|Goods and Services", title, re.I):
|
||||
continue
|
||||
start = _english_date(_text(card.get_text(" ", strip=True)))
|
||||
if not start or start < now:
|
||||
continue
|
||||
item = _item(
|
||||
source=source, title=title, start_date=start,
|
||||
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
|
||||
location="线上", source_language="en",
|
||||
)
|
||||
if item:
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_cbp(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for card in soup.select("li.usa-icon-list__item"):
|
||||
link = card.select_one("a[href]")
|
||||
if not link:
|
||||
continue
|
||||
title = _text(link.get_text(" ", strip=True))
|
||||
if not (TEXTILE_TERMS.search(title) or re.search(r"EAPA|Trade Violations?", title, re.I)):
|
||||
continue
|
||||
start = _english_date(_text(card.get_text(" ", strip=True)))
|
||||
if not start or start < now:
|
||||
continue
|
||||
item = _item(
|
||||
source=source, title=title, start_date=start,
|
||||
url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at,
|
||||
location="线上", source_language="en",
|
||||
)
|
||||
if item:
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_ccpit(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
match = re.search(r"var\s+meetingList\s*=\s*(\{[\s\S]*?\});", html)
|
||||
if not match:
|
||||
return []
|
||||
payload = json.loads(match.group(1))
|
||||
rows: list[dict[str, Any]] = []
|
||||
for meeting in payload.get("content") or []:
|
||||
raw_title = _text(str(meeting.get("title") or ""))
|
||||
profile_text = _text(" ".join(str(meeting.get(key) or "") for key in ("title", "briefIntroduction", "agenda", "targetWebsite")))
|
||||
if not re.search(r"出海|跨境|外贸|数字贸易|知识产权|商标|海关|关税|纺织|家纺|服装|平台", profile_text, re.I):
|
||||
continue
|
||||
start_value = str(meeting.get("startDate") or "")[:10]
|
||||
if not re.match(r"20\d{2}-\d{2}-\d{2}", start_value):
|
||||
continue
|
||||
start = date.fromisoformat(start_value)
|
||||
if start < now or int(meeting.get("status") or 0) not in (2, 3):
|
||||
continue
|
||||
location = "线上" if meeting.get("onLineMeeting") else _text(" ".join(str(meeting.get(key) or "") for key in ("addressProvince", "addressCity", "addressDetail"))).strip() or "中国"
|
||||
item = _item(
|
||||
source=source, title=raw_title, start_date=start,
|
||||
url=urljoin(source["url"], f"/meetings/{meeting.get('id')}"), checked_at=checked_at,
|
||||
location=location, source_language="zh-CN", profile_text=profile_text,
|
||||
)
|
||||
if item:
|
||||
item["end_date"] = str(meeting.get("endDate") or start_value)[:10]
|
||||
item["registration_deadline"] = str((meeting.get("signSetting") or {}).get("signEndDate") or "")[:10]
|
||||
item["free"] = bool(meeting.get("free"))
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_amazon_global(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for card in soup.select(".background-color-white.has-box-shadow"):
|
||||
value = _text(card.get_text(" ", strip=True))
|
||||
date_match = re.search(r"(?:When:\s*)?([A-Z][a-z]+\s+\d{1,2}(?:-\d{1,2})?,\s+20\d{2})", value)
|
||||
if not date_match:
|
||||
continue
|
||||
parts = date_match.group(1).replace("–", "-").split(",")
|
||||
day_match = re.search(r"([A-Z][a-z]+)\s+(\d{1,2})(?:-\d{1,2})?", parts[0])
|
||||
if not day_match:
|
||||
continue
|
||||
start = date(int(parts[1].strip()), MONTHS[day_match.group(1)], int(day_match.group(2)))
|
||||
if start < now:
|
||||
continue
|
||||
title_node = card.select_one("h2.heading.font-size-xlarge")
|
||||
title = _text(title_node.get_text(" ", strip=True) if title_node else value[:date_match.start()])
|
||||
link = next((node for node in card.select("a[href]") if "register" in _text(node.get_text(" ", strip=True)).lower() or "注册" in node.get_text()), card.select_one("a[href]"))
|
||||
if not link:
|
||||
continue
|
||||
item = _item(source=source, title=title, start_date=start, url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at, location="线上", source_language="zh-CN", profile_text=value)
|
||||
if item:
|
||||
item["platforms"] = ["Amazon"]
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_ebay(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for script in BeautifulSoup(html, "html.parser").select("script"):
|
||||
text = script.string or script.get_text()
|
||||
if "start_date" not in text or "events/details" not in text:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
stack: list[Any] = [payload]
|
||||
while stack:
|
||||
value = stack.pop()
|
||||
if isinstance(value, dict):
|
||||
if value.get("start_date") and value.get("title") and value.get("url") and "/events/details/" in str(value["url"]):
|
||||
start = date.fromisoformat(str(value["start_date"])[:10])
|
||||
if start >= now and str(value["url"]) not in seen:
|
||||
seen.add(str(value["url"]))
|
||||
item = _item(source=source, title=_text(str(value["title"])), start_date=start, url=str(value["url"]), checked_at=checked_at, location="线上", source_language="en", profile_text=str(value["title"]))
|
||||
if item:
|
||||
item["platforms"] = ["eBay"]
|
||||
rows.append(item)
|
||||
stack.extend(value.values())
|
||||
elif isinstance(value, list):
|
||||
stack.extend(value)
|
||||
return rows
|
||||
|
||||
|
||||
def _ecl_date(card: Any) -> date | None:
|
||||
text = _text(card.get_text(" ", strip=True))
|
||||
match = re.search(r"\b(\d{1,2})(?:-\d{1,2})?\s+([A-Za-z]+)\s+(20\d{2})\b", text)
|
||||
if not match:
|
||||
return None
|
||||
month = MONTHS.get(match.group(2)) or MONTHS.get(match.group(2)[:3])
|
||||
return date(int(match.group(3)), month, int(match.group(1))) if month else None
|
||||
|
||||
|
||||
def _parse_eu_cards(html: str, source: dict[str, str], now: date, checked_at: str, *, trade: bool = False) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for card in soup.select("article.ecl-content-item.ecl-content-item--inline"):
|
||||
link = card.select_one("a[data-ecl-title-link][href]") or card.select_one("a[href]")
|
||||
start = _ecl_date(card)
|
||||
if not link or not start or start < now:
|
||||
continue
|
||||
title = _text(link.get_text(" ", strip=True))
|
||||
profile_text = _text(card.get_text(" ", strip=True))
|
||||
if trade and not re.search(r"custom|tariff|trade|export|digital|supply|textile|policy|关税|贸易|海关", profile_text, re.I):
|
||||
continue
|
||||
if not trade and not re.search(r"trademark|design|copyright|brand|IP|知识产权|商标|版权", profile_text, re.I):
|
||||
continue
|
||||
location = "线上" if re.search(r"Live streaming available|Webinar", profile_text, re.I) else "欧洲"
|
||||
item = _item(source=source, title=title, start_date=start, url=urljoin(source["url"], str(link.get("href") or "")), checked_at=checked_at, location=location, source_language="en", profile_text=profile_text)
|
||||
if item:
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_eu_ip(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
return _parse_eu_cards(html, source, now, checked_at, trade=False)
|
||||
|
||||
|
||||
def _parse_eu_trade(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
return _parse_eu_cards(html, source, now, checked_at, trade=True)
|
||||
|
||||
|
||||
def _parse_uspto(html: str, source: dict[str, str], now: date, checked_at: str) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for row in soup.select("table.views-table.views-view-table.cols-4 tbody tr[date]"):
|
||||
raw_date = str(row.get("date") or "")
|
||||
if not re.fullmatch(r"20\d{6}", raw_date):
|
||||
continue
|
||||
start = date(int(raw_date[:4]), int(raw_date[4:6]), int(raw_date[6:]))
|
||||
if start < now:
|
||||
continue
|
||||
title_node = row.select_one("td.name a[href]")
|
||||
if not title_node:
|
||||
continue
|
||||
title = _text(title_node.get_text(" ", strip=True))
|
||||
profile_text = _text(row.get_text(" ", strip=True))
|
||||
if re.search(r"CANCELED|POSTPONED", profile_text, re.I) or not re.search(r"trademark|brand|counterfeit|design|intellectual property|商标|品牌|假冒|外观", profile_text, re.I):
|
||||
continue
|
||||
location = _text((row.select_one("td.location") or row).get_text(" ", strip=True))
|
||||
item = _item(source=source, title=title, start_date=start, url=urljoin(source["url"], str(title_node.get("href") or "")), checked_at=checked_at, location=location or "线上", source_language="en", profile_text=profile_text)
|
||||
if item:
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
PARSERS = {
|
||||
"activity_cifnews": _parse_cifnews,
|
||||
"activity_amazon": _parse_amazon,
|
||||
"activity_wipo": _parse_wipo,
|
||||
"activity_cbp": _parse_cbp,
|
||||
"activity_ccpit": _parse_ccpit,
|
||||
"activity_amazon_global": _parse_amazon_global,
|
||||
"activity_ebay": _parse_ebay,
|
||||
"activity_eu_ip": _parse_eu_ip,
|
||||
"activity_uspto": _parse_uspto,
|
||||
"activity_eu_trade": _parse_eu_trade,
|
||||
}
|
||||
|
||||
|
||||
def build_activities(now: date, checked_at: str, session: requests.Session) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""返回未来活动与逐源抓取状态;活动源不冒充原 52 个权威资讯源。"""
|
||||
activities: list[dict[str, Any]] = []
|
||||
statuses: list[dict[str, Any]] = []
|
||||
for source in ACTIVITY_SOURCES:
|
||||
try:
|
||||
response = session.get(source["url"], timeout=45, allow_redirects=True)
|
||||
response.raise_for_status()
|
||||
parsed = PARSERS[source["id"]](response.text, source, now, checked_at)
|
||||
activities.extend(parsed)
|
||||
statuses.append({
|
||||
**source,
|
||||
"ok": True,
|
||||
"status_code": response.status_code,
|
||||
"final_url": response.url,
|
||||
"items": len(parsed),
|
||||
"checked_at": checked_at,
|
||||
"error": "",
|
||||
})
|
||||
except (requests.RequestException, ValueError) as error:
|
||||
statuses.append({
|
||||
**source,
|
||||
"ok": False,
|
||||
"status_code": 0,
|
||||
"final_url": source["url"],
|
||||
"items": 0,
|
||||
"checked_at": checked_at,
|
||||
"error": str(error),
|
||||
})
|
||||
unique: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for item in activities:
|
||||
item["days_until"] = (date.fromisoformat(item["start_date"]) - now).days
|
||||
key = (str(item["start_date"]), re.sub(r"\W+", "", str(item["title"]).lower()))
|
||||
existing = unique.get(key)
|
||||
if not existing or (item["official_activity"] and not existing["official_activity"]):
|
||||
unique[key] = item
|
||||
ordered = sorted(
|
||||
unique.values(),
|
||||
key=lambda item: (item["start_date"], 0 if item["official_activity"] else 1, item["title"]),
|
||||
)
|
||||
return ordered, statuses
|
||||
|
||||
|
||||
def merge_previous_activities(current: list[dict[str, Any]], previous: list[dict[str, Any]], now: date) -> list[dict[str, Any]]:
|
||||
"""当前抓取优先,补回上一快照中仍未过期的活动,避免源站窗口滚动造成数据闪断。"""
|
||||
merged: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for item in current:
|
||||
key = (str(item.get("start_date") or ""), re.sub(r"\W+", "", str(item.get("title") or "").lower()))
|
||||
if key[0] and key[1]:
|
||||
item["days_until"] = (date.fromisoformat(key[0]) - now).days
|
||||
merged[key] = item
|
||||
# ponytail: 仅按结束日期保留上一快照,活动撤回或改期需下一次抓取覆盖。
|
||||
for item in previous:
|
||||
try:
|
||||
end_date = date.fromisoformat(str(item.get("end_date") or item.get("start_date") or "")[:10])
|
||||
except ValueError:
|
||||
continue
|
||||
if end_date < now:
|
||||
continue
|
||||
key = (str(item.get("start_date") or ""), re.sub(r"\W+", "", str(item.get("title") or "").lower()))
|
||||
if key[0] and key[1] and key not in merged:
|
||||
item = dict(item)
|
||||
item["days_until"] = (date.fromisoformat(key[0]) - now).days
|
||||
merged[key] = item
|
||||
return sorted(merged.values(), key=lambda item: (item["start_date"], 0 if item.get("official_activity") else 1, item.get("title") or ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
session = requests.Session()
|
||||
session.headers["User-Agent"] = "Mozilla/5.0"
|
||||
rows, source_statuses = build_activities(date.today(), datetime.now().isoformat(), session)
|
||||
print(f"活动 {len(rows)} 条,来源 {sum(item['ok'] for item in source_statuses)}/{len(source_statuses)}")
|
||||
@@ -0,0 +1,896 @@
|
||||
#!/usr/bin/env python3
|
||||
"""备货日历的市场语境、经营分析与跨国节日聚合。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
COUNTRY_DETAILS = {
|
||||
"CN": ("中国", "东亚", "china", ["简体中文"], ["天猫", "京东", "抖音电商"]),
|
||||
"US": ("美国", "北美", "north-america", ["英语", "西班牙语"], ["Amazon US", "Walmart", "eBay", "Etsy", "TikTok Shop", "Temu"]),
|
||||
"GB": ("英国", "英国及爱尔兰", "british-isles", ["英语"], ["Amazon UK", "eBay UK", "Etsy"]),
|
||||
"DE": ("德国", "中西欧", "central-europe", ["德语"], ["Amazon DE", "OTTO", "eBay DE", "Temu"]),
|
||||
"FR": ("法国", "中西欧", "central-europe", ["法语"], ["Amazon FR", "Cdiscount", "Etsy", "Temu"]),
|
||||
"IT": ("意大利", "南欧", "southern-europe", ["意大利语"], ["Amazon IT", "eBay IT", "Etsy"]),
|
||||
"ES": ("西班牙", "南欧", "southern-europe", ["西班牙语"], ["Amazon ES", "eBay ES", "Etsy"]),
|
||||
"NL": ("荷兰", "中西欧", "central-europe", ["荷兰语"], ["bol.com", "Amazon NL"]),
|
||||
"CA": ("加拿大", "北美", "north-america", ["英语", "法语"], ["Amazon CA", "Walmart Canada", "eBay"]),
|
||||
"AU": ("澳大利亚", "大洋洲", "oceania", ["英语"], ["Amazon AU", "eBay AU"]),
|
||||
"JP": ("日本", "东亚", "japan", ["日语"], ["Amazon Japan", "乐天市场", "Yahoo!购物", "Temu"]),
|
||||
"KR": ("韩国", "东亚", "korea", ["韩语"], ["Coupang", "Naver Shopping", "Gmarket", "Temu"]),
|
||||
"BR": ("巴西", "拉丁美洲", "latin-america", ["葡萄牙语"], ["Mercado Libre", "Amazon Brazil", "Shopee"]),
|
||||
"MX": ("墨西哥", "拉丁美洲", "latin-america", ["西班牙语"], ["Mercado Libre", "Amazon Mexico", "Walmart Mexico"]),
|
||||
"NZ": ("新西兰", "大洋洲", "oceania", ["英语", "毛利语"], ["Trade Me", "Amazon AU"]),
|
||||
"SG": ("新加坡", "东南亚", "southeast-asia", ["英语", "简体中文", "马来语", "泰米尔语"], ["Shopee", "Lazada", "Amazon SG", "TikTok Shop"]),
|
||||
"ID": ("印度尼西亚", "东南亚", "southeast-asia", ["印度尼西亚语"], ["Shopee", "Tokopedia", "Lazada", "TikTok Shop"]),
|
||||
"VN": ("越南", "东南亚", "southeast-asia", ["越南语"], ["Shopee", "Lazada", "TikTok Shop"]),
|
||||
"PH": ("菲律宾", "东南亚", "southeast-asia", ["菲律宾语", "英语"], ["Shopee", "Lazada", "TikTok Shop"]),
|
||||
"ZA": ("南非", "南部非洲", "southern-africa", ["英语", "祖鲁语", "南非荷兰语"], ["Takealot", "Amazon South Africa"]),
|
||||
"PL": ("波兰", "中东欧", "central-europe", ["波兰语"], ["Allegro", "Amazon DE", "Empik"]),
|
||||
"SE": ("瑞典", "北欧", "nordic", ["瑞典语"], ["Amazon SE", "CDON"]),
|
||||
"DK": ("丹麦", "北欧", "nordic", ["丹麦语"], ["Amazon DE", "CDON"]),
|
||||
"NO": ("挪威", "北欧", "nordic", ["挪威语"], ["Amazon DE", "CDON"]),
|
||||
"FI": ("芬兰", "北欧", "nordic", ["芬兰语", "瑞典语"], ["Amazon DE", "CDON"]),
|
||||
"BE": ("比利时", "中西欧", "central-europe", ["荷兰语", "法语", "德语"], ["bol.com", "Amazon FR", "Amazon DE"]),
|
||||
"AT": ("奥地利", "中西欧", "central-europe", ["德语"], ["Amazon DE", "eBay DE"]),
|
||||
"CH": ("瑞士", "中西欧", "central-europe", ["德语", "法语", "意大利语"], ["Galaxus", "Amazon DE", "Amazon FR"]),
|
||||
"PT": ("葡萄牙", "南欧", "southern-europe", ["葡萄牙语"], ["Amazon ES", "Worten", "Etsy"]),
|
||||
"IE": ("爱尔兰", "英国及爱尔兰", "british-isles", ["英语", "爱尔兰语"], ["Amazon UK", "eBay", "Etsy"]),
|
||||
"CZ": ("捷克", "中东欧", "central-europe", ["捷克语"], ["Alza", "Amazon DE"]),
|
||||
"TR": ("土耳其", "欧亚市场", "turkiye", ["土耳其语"], ["Trendyol", "Hepsiburada", "Amazon TR"]),
|
||||
"AE": ("阿联酋", "中东", "islamic", ["阿拉伯语", "英语"], ["Amazon.ae", "Noon", "Temu"]),
|
||||
"RU": ("俄罗斯", "俄罗斯及欧亚", "central-europe", ["俄语"], ["Ozon", "Wildberries", "Yandex Market"]),
|
||||
}
|
||||
|
||||
PLATFORM_PLAYBOOKS = {
|
||||
"Amazon": "核对活动提报与最晚入仓日;主图和标题使用当地语言,尺寸、材质、克重及洗护信息完整。",
|
||||
"Walmart": "核对促销资格、配送承诺和库存可售天数;优先准备高性价比套装与稳定补货。",
|
||||
"TikTok Shop": "提前准备当地语言短视频、达人样品和直播库存;避免未经授权使用节日、赛事或人物元素。",
|
||||
"Shopee": "核对站点大促报名、跨境仓截单和包邮门槛;轻小件、组合装和本地语言素材优先。",
|
||||
"Lazada": "核对站点活动日历、LGS 物流时效与优惠券成本;按东南亚气候准备轻薄、快干产品。",
|
||||
"Mercado Libre": "核对 Mercado Envios 入仓与配送承诺;西班牙语/葡萄牙语页面、尺寸单位和税费表达要本地化。",
|
||||
"Etsy": "强调原创图案、材质故事与个性化能力;上架前先完成商标、版权和节庆符号检索。",
|
||||
"eBay": "核对目的国配送承诺、退货地址和商品属性;标题关键词、尺寸单位和税费说明按站点本地化。",
|
||||
"Temu": "核对站点与托管模式、申报价和仓配时效;用高性价比组合装测试,不要用低价掩盖材质与尺寸信息。",
|
||||
"Ozon": "准备俄语标题、尺寸和认证信息;核对跨境物流、退货与卢布定价,保暖床品按当地季节提前排期。",
|
||||
"Wildberries": "核对跨境招商、仓配和罚则;俄语商品属性、条码与包装必须在发货前完成。",
|
||||
"Coupang": "准备韩语商品页与快速履约方案;床品尺寸、洗护和材质说明按韩国消费者习惯表达。",
|
||||
"乐天市场": "通过日本主体或代理核对店铺资格;用日语细化尺寸、材质、收纳与独居场景。",
|
||||
"OTTO": "先确认欧盟主体、税务与德语客服条件;适合已有欧洲合规和稳定补货能力的品牌。",
|
||||
"Cdiscount": "核对法国税务、EPR 与法语客服;床品和桌布需使用当地尺寸与材质术语。",
|
||||
}
|
||||
|
||||
COUNTRY_NAMES = {code: values[0] for code, values in COUNTRY_DETAILS.items()}
|
||||
COUNTRY_MARKETS = {code: (values[1], values[2]) for code, values in COUNTRY_DETAILS.items()}
|
||||
COUNTRY_ENGLISH_NAMES = {
|
||||
"CN": "China", "US": "United States", "GB": "United Kingdom", "DE": "Germany", "FR": "France",
|
||||
"IT": "Italy", "ES": "Spain", "NL": "Netherlands", "CA": "Canada", "AU": "Australia",
|
||||
"JP": "Japan", "KR": "South Korea", "BR": "Brazil", "MX": "Mexico", "NZ": "New Zealand",
|
||||
"SG": "Singapore", "ID": "Indonesia", "VN": "Vietnam", "PH": "Philippines", "ZA": "South Africa",
|
||||
"PL": "Poland", "SE": "Sweden", "DK": "Denmark", "NO": "Norway", "FI": "Finland", "BE": "Belgium",
|
||||
"AT": "Austria", "CH": "Switzerland", "PT": "Portugal", "IE": "Ireland", "CZ": "Czechia",
|
||||
"TR": "Türkiye", "AE": "United Arab Emirates", "RU": "Russia",
|
||||
}
|
||||
|
||||
LOCAL_HOLIDAY_NAMES: dict[str, dict[str, tuple[str, str]]] = {
|
||||
"back-to-school": {
|
||||
"US": ("Back to School / Back to College", "英语"),
|
||||
"GB": ("Back to School / Autumn Term", "英语"),
|
||||
"DE": ("Schulanfang / Schulbeginn", "德语"),
|
||||
"FR": ("Rentrée scolaire", "法语"),
|
||||
"JP": ("新学期(Shingakki)", "日语"),
|
||||
"CA": ("Back to School / Rentrée scolaire", "英语 / 法语"),
|
||||
"AU": ("Back to School / Term 1", "英语"),
|
||||
},
|
||||
"black-friday": {"US": ("Black Friday", "英语"), "GB": ("Black Friday", "英语"), "DE": ("Black Friday", "德语市场通用英文名"), "FR": ("Black Friday", "法语市场通用英文名")},
|
||||
"cyber-monday": {"US": ("Cyber Monday", "英语")},
|
||||
"singles-day": {"CN": ("双十一", "简体中文")},
|
||||
"winter-holiday": {"US": ("Holiday Season", "英语"), "CA": ("Holiday Season / Saison des Fêtes", "英语 / 法语")},
|
||||
"christmas": {
|
||||
"US": ("Christmas Day", "英语"), "GB": ("Christmas Day", "英语"), "DE": ("Erster Weihnachtstag", "德语"),
|
||||
"FR": ("Noël", "法语"), "IT": ("Natale", "意大利语"), "ES": ("Navidad", "西班牙语"),
|
||||
"PT": ("Natal", "葡萄牙语"), "PL": ("Boże Narodzenie", "波兰语"), "BR": ("Natal", "葡萄牙语"),
|
||||
},
|
||||
"boxing-day": {"GB": ("Boxing Day", "英语"), "CA": ("Boxing Day / Lendemain de Noël", "英语 / 法语"), "AU": ("Boxing Day", "英语"), "NZ": ("Boxing Day", "英语")},
|
||||
"st-stephens-day": {
|
||||
"CA": ("St. Stephen's Day / Boxing Day", "英语 / 法语"), "BE": ("Saint-Étienne / Sint-Stefanus", "法语 / 荷兰语"),
|
||||
"DK": ("2. juledag", "丹麦语"), "AT": ("Stefanitag", "德语"), "DE": ("Zweiter Weihnachtstag", "德语"),
|
||||
"IT": ("Santo Stefano", "意大利语"), "NO": ("Andre juledag", "挪威语"), "CZ": ("2. svátek vánoční", "捷克语"),
|
||||
"PL": ("Drugi dzień Bożego Narodzenia", "波兰语"), "IE": ("Lá Fhéile Stiofáin", "爱尔兰语 / 英语"),
|
||||
"SE": ("Annandag jul", "瑞典语"), "CH": ("Stephanstag", "德语 / 法语 / 意大利语"),
|
||||
"FI": ("Tapaninpäivä", "芬兰语 / 瑞典语"), "NL": ("Tweede kerstdag", "荷兰语"),
|
||||
"PT": ("Primeira Oitava", "葡萄牙语"), "ES": ("Fiesta de San Esteban", "西班牙语"),
|
||||
"AU": ("Boxing Day (observed)", "英语"), "GB": ("Boxing Day (observed)", "英语"),
|
||||
},
|
||||
"lunar-new-year": {"CN": ("春节", "简体中文"), "KR": ("설날", "韩语"), "VN": ("Tết Nguyên Đán", "越南语"), "SG": ("农历新年 / Chinese New Year", "简体中文 / 英语")},
|
||||
"eid-fitr": {"AE": ("عيد الفطر", "阿拉伯语"), "TR": ("Ramazan Bayramı", "土耳其语"), "ID": ("Idul Fitri", "印度尼西亚语")},
|
||||
"eid-adha": {"AE": ("عيد الأضحى", "阿拉伯语"), "TR": ("Kurban Bayramı", "土耳其语"), "ID": ("Idul Adha", "印度尼西亚语")},
|
||||
"assumption-day": {
|
||||
"AT": ("Mariä Himmelfahrt", "德语"), "DE": ("Mariä Himmelfahrt", "德语"), "IT": ("Ferragosto / Assunzione", "意大利语"),
|
||||
"BE": ("Onze-Lieve-Vrouw-Hemelvaart / Assomption", "荷兰语 / 法语"), "FR": ("Assomption", "法语"),
|
||||
"PL": ("Wniebowzięcie Najświętszej Maryi Panny", "波兰语"), "CH": ("Mariä Himmelfahrt / Assomption / Assunzione", "德语 / 法语 / 意大利语"),
|
||||
"PT": ("Assunção de Nossa Senhora", "葡萄牙语"), "ES": ("Asunción", "西班牙语"),
|
||||
},
|
||||
"indonesia-independence": {"ID": ("Hari Kemerdekaan Republik Indonesia", "印度尼西亚语")},
|
||||
}
|
||||
|
||||
FESTIVAL_TARGET_COUNTRIES = {
|
||||
"eid-fitr": ("AE", "TR", "ID"),
|
||||
"eid-adha": ("AE", "TR", "ID"),
|
||||
}
|
||||
|
||||
HOLIDAY_TRANSLATIONS = {
|
||||
"new year's day": "元旦", "new year holiday": "新年假期", "chinese new year": "春节",
|
||||
"lunar new year": "农历新年", "spring festival": "春节", "good friday": "耶稣受难日",
|
||||
"easter sunday": "复活节", "easter monday": "复活节星期一", "christmas day": "圣诞节",
|
||||
"boxing day": "节礼日", "st. stephen's day": "圣斯蒂芬节", "labour day": "劳动节", "labor day": "劳动节",
|
||||
"independence day": "独立日", "national day": "国庆日", "republic day": "共和国日",
|
||||
"constitution day": "宪法日", "liberation day": "解放日", "victory day": "胜利日",
|
||||
"memorial day": "阵亡将士纪念日", "thanksgiving day": "感恩节", "assumption": "圣母升天节",
|
||||
"assumption day": "圣母升天节", "assumption of the virgin mary": "圣母升天节",
|
||||
"ascension day": "耶稣升天节", "all saints' day": "诸圣节", "epiphany": "主显节",
|
||||
"corpus christi": "基督圣体节", "reformation day": "宗教改革纪念日",
|
||||
"whit monday": "圣灵降临节星期一", "pentecost monday": "圣灵降临节星期一",
|
||||
"king's birthday": "国王诞辰", "mountain day": "山之日", "marine day": "海之日",
|
||||
"respect for the aged day": "敬老日", "culture day": "文化日", "children's day": "儿童节",
|
||||
}
|
||||
|
||||
SUBDIVISION_NAMES = {
|
||||
"DE-BW": "巴登-符腾堡州", "DE-BY": "巴伐利亚州", "DE-BE": "柏林州", "DE-BB": "勃兰登堡州",
|
||||
"DE-HB": "不来梅州", "DE-HH": "汉堡州", "DE-HE": "黑森州", "DE-MV": "梅克伦堡-前波美拉尼亚州",
|
||||
"DE-NI": "下萨克森州", "DE-NW": "北莱茵-威斯特法伦州", "DE-RP": "莱茵兰-普法尔茨州", "DE-SL": "萨尔州",
|
||||
"DE-SN": "萨克森州", "DE-ST": "萨克森-安哈尔特州", "DE-SH": "石勒苏益格-荷尔斯泰因州", "DE-TH": "图林根州",
|
||||
"CH-AG": "阿尔高州", "CH-AI": "内阿彭策尔州", "CH-AR": "外阿彭策尔州", "CH-BE": "伯尔尼州",
|
||||
"CH-BL": "巴塞尔乡村州", "CH-BS": "巴塞尔城市州", "CH-FR": "弗里堡州", "CH-GE": "日内瓦州",
|
||||
"CH-GL": "格拉鲁斯州", "CH-GR": "格劳宾登州", "CH-JU": "汝拉州", "CH-LU": "卢塞恩州",
|
||||
"CH-NE": "纳沙泰尔州", "CH-NW": "下瓦尔登州", "CH-OW": "上瓦尔登州", "CH-SG": "圣加仑州",
|
||||
"CH-SH": "沙夫豪森州", "CH-SO": "索洛图恩州", "CH-SZ": "施维茨州", "CH-TG": "图尔高州",
|
||||
"CH-TI": "提契诺州", "CH-UR": "乌里州", "CH-VD": "沃州", "CH-VS": "瓦莱州",
|
||||
"CH-ZG": "楚格州", "CH-ZH": "苏黎世州",
|
||||
"AU-ACT": "首都领地", "AU-NSW": "新南威尔士州", "AU-NT": "北领地", "AU-QLD": "昆士兰州",
|
||||
"AU-SA": "南澳大利亚州", "AU-TAS": "塔斯马尼亚州", "AU-VIC": "维多利亚州", "AU-WA": "西澳大利亚州",
|
||||
"CA-AB": "艾伯塔省", "CA-BC": "不列颠哥伦比亚省", "CA-MB": "马尼托巴省", "CA-NB": "新不伦瑞克省",
|
||||
"CA-NL": "纽芬兰与拉布拉多省", "CA-NS": "新斯科舍省", "CA-NT": "西北地区", "CA-NU": "努纳武特地区",
|
||||
"CA-ON": "安大略省", "CA-PE": "爱德华王子岛省", "CA-QC": "魁北克省", "CA-SK": "萨斯喀彻温省", "CA-YT": "育空地区",
|
||||
}
|
||||
|
||||
|
||||
def calendar_scope(is_global: bool, subdivision_codes: list[str]) -> tuple[list[str], str]:
|
||||
if is_global or not subdivision_codes:
|
||||
return ["全国"], "全国适用"
|
||||
areas = [SUBDIVISION_NAMES.get(code, code) for code in subdivision_codes]
|
||||
preview = "、".join(areas[:4])
|
||||
suffix = f"等 {len(areas)} 个州/省级地区" if len(areas) > 4 else ""
|
||||
return areas, f"仅适用于{preview}{suffix}"
|
||||
|
||||
FESTIVAL_PROFILES = {
|
||||
"back-to-school": {
|
||||
"title": "美国开学季与大学返校季", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "美国中小学返校与大学宿舍入住消费季,核心机会集中在 Twin XL 宿舍床品、浴巾、收纳和一站式套装。",
|
||||
"scenes": ["大学宿舍入住", "学生公寓布置", "返校采购"],
|
||||
"products": ["Twin XL 床品套装", "枕芯与床垫保护套", "浴巾套装", "宿舍收纳软品"],
|
||||
"design": ["商品页明确 Twin XL 尺寸和套装清单", "使用宿舍小空间与耐洗场景"],
|
||||
"avoid": ["不要把普通单人床尺寸直接当作 Twin XL;避免漏写套装件数"],
|
||||
},
|
||||
"black-friday": {
|
||||
"title": "黑色星期五", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "北美感恩节后的大型零售促销节点,并已扩展到欧洲等跨境市场。",
|
||||
"scenes": ["大促折扣", "礼赠采购", "冬季换新"],
|
||||
"products": ["冬季四件套", "盖毯组合装", "毛巾礼盒", "轻小件软装"],
|
||||
"design": ["突出折扣前后价与套装价值", "准备黑金之外的品牌主色素材"],
|
||||
"avoid": ["不要只降价不核算平台费用、退货和海外仓成本"],
|
||||
},
|
||||
"cyber-monday": {
|
||||
"title": "网络星期一", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "紧接黑色星期五的线上零售促销节点,重点考验移动端转化、库存同步和快速履约。",
|
||||
"scenes": ["线上闪促", "移动端下单", "大促补货"],
|
||||
"products": ["轻量盖毯", "枕套组合", "毛巾套装", "快递友好包装"],
|
||||
"design": ["主图先讲尺寸、材质和折扣", "准备短视频与移动端竖版素材"],
|
||||
"avoid": ["不要使用体积过大、运费吞噬毛利的低客单产品做主推"],
|
||||
},
|
||||
"singles-day": {
|
||||
"title": "双十一购物节", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "中国电商平台年度集中促销节点,也会影响跨境华人市场和部分海外平台营销节奏。",
|
||||
"scenes": ["家居焕新", "冬季换被", "礼盒囤货"],
|
||||
"products": ["保暖床品", "高客单套装", "智能温控产品", "礼盒装毛巾"],
|
||||
"design": ["分预售、付尾款和现货三套素材", "把克重、支数和保暖指标写清楚"],
|
||||
"avoid": ["不要照搬国内促销语言到非中文市场"],
|
||||
},
|
||||
"winter-holiday": {
|
||||
"title": "北美冬季假日消费季", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "从感恩节延续到圣诞节前的礼赠、家庭聚会和冬季家居消费周期。",
|
||||
"scenes": ["家庭聚会", "节日礼赠", "冬季家居焕新"],
|
||||
"products": ["保暖床品", "沙发盖毯", "餐桌布艺", "礼赠套装"],
|
||||
"design": ["建立传统红绿、自然木色和中性冬季三套方案", "礼盒需适配快递与退换货"],
|
||||
"avoid": ["不要把宗教符号用于不明确的通用礼赠款"],
|
||||
},
|
||||
"christmas": {
|
||||
"title": "圣诞节", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "欧美、大洋洲及部分亚洲市场的重要家庭团聚与礼赠节日。",
|
||||
"scenes": ["家庭团聚", "节日装饰", "礼赠与旅行"],
|
||||
"products": ["保暖四件套", "盖毯", "抱枕套", "餐桌布艺", "节庆礼盒"],
|
||||
"design": ["准备经典、自然和现代简约三种节庆风格", "兼顾公寓小空间与礼赠包装"],
|
||||
"avoid": ["面向多宗教市场时减少强宗教图案,改用冬季与团聚表达"],
|
||||
},
|
||||
"boxing-day": {
|
||||
"title": "节礼日", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "英国、加拿大、澳大利亚和新西兰等市场圣诞节后的公共假日与清仓促销节点。",
|
||||
"scenes": ["节后清仓", "礼品卡消费", "居家换新"],
|
||||
"products": ["清仓床品套装", "基础毛巾", "盖毯", "次年常青款"],
|
||||
"design": ["突出库存有限与套装优惠", "把退换货规则写清楚"],
|
||||
"avoid": ["不要把节礼日误译为单纯送礼日"],
|
||||
},
|
||||
"lunar-new-year": {
|
||||
"title": "农历新年", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "中国及多个东亚、东南亚市场的重要团聚、焕新与礼赠节庆。",
|
||||
"scenes": ["家庭团聚", "新居焕新", "节庆礼赠"],
|
||||
"products": ["红金床品", "新年礼盒", "沙发软装", "餐桌布艺"],
|
||||
"design": ["按中国、越南、韩国等市场分别处理生肖、文字和色彩", "准备华人市场双语素材"],
|
||||
"avoid": ["不要把单一中国春节视觉直接套用到所有亚洲市场"],
|
||||
},
|
||||
"eid-fitr": {
|
||||
"title": "开斋节", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "斋月结束后的重要穆斯林节日,家庭团聚、礼赠与居家接待需求集中。",
|
||||
"scenes": ["家庭团聚", "居家宴请", "节庆礼赠"],
|
||||
"products": ["礼赠床品", "祈祷毯", "餐桌布艺", "宾客毛巾"],
|
||||
"design": ["使用几何、植物和建筑纹样", "为海湾与东南亚穆斯林市场分别配色"],
|
||||
"avoid": ["避免人物或宗教符号的随意商业化使用"],
|
||||
},
|
||||
"eid-adha": {
|
||||
"title": "宰牲节", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "穆斯林重要节日,通常伴随家庭团聚、礼赠、旅行和居家接待。",
|
||||
"scenes": ["家庭接待", "礼赠", "旅行与酒店"],
|
||||
"products": ["礼赠床品", "客用毛巾", "祈祷毯", "酒店布草"],
|
||||
"design": ["采用克制的几何和金属色点缀", "关注耐洗和礼盒陈列"],
|
||||
"avoid": ["不要使用与宗教礼仪不符的图案和促销文案"],
|
||||
},
|
||||
"easter": {
|
||||
"title": "复活节假期", "relevance": "medium", "decision": "按区域观察",
|
||||
"what": "欧美等市场的春季宗教与公共假期,常与家庭活动、短途旅行和春季换新叠加。",
|
||||
"scenes": ["春季换新", "家庭活动", "短途旅行"],
|
||||
"products": ["轻薄床品", "野餐毯", "淡彩抱枕", "儿童房软装"],
|
||||
"design": ["以春季植物、柔和色彩和户外场景为主", "宗教图案只用于明确受众"],
|
||||
"avoid": ["不同国家的放假范围不同,不要统一承诺配送时效"],
|
||||
},
|
||||
"assumption-day": {
|
||||
"title": "圣母升天节", "relevance": "medium", "decision": "按区域观察",
|
||||
"what": "8 月 15 日的基督教传统节日,在法国、意大利、西班牙、奥地利等市场为全国公共假日;德国和瑞士只覆盖部分州,且常与欧洲夏季休假叠加。",
|
||||
"scenes": ["夏季长周末", "家庭出行", "酒店与民宿补货"],
|
||||
"products": ["轻薄床品", "浴巾", "旅行毯", "酒店布草"],
|
||||
"design": ["以夏季、海岸和天然材质表达为主", "各国素材分别使用当地语言"],
|
||||
"avoid": ["它不是所有国家都适合强促销的消费节,不要统一压主题库存"],
|
||||
},
|
||||
"thanksgiving": {
|
||||
"title": "感恩节", "relevance": "high", "decision": "值得专项备货",
|
||||
"what": "美国、加拿大等市场的家庭团聚节日,但日期和消费节奏并不相同。",
|
||||
"scenes": ["家庭聚餐", "客房整理", "节日礼赠"],
|
||||
"products": ["餐桌布艺", "客房床品", "厨房毛巾", "沙发盖毯"],
|
||||
"design": ["使用秋季自然色与家庭场景", "美国和加拿大分开排期"],
|
||||
"avoid": ["不要把美国感恩节日期直接套到加拿大"],
|
||||
},
|
||||
"new-year": {
|
||||
"title": "元旦与新年假期", "relevance": "medium", "decision": "按区域观察",
|
||||
"what": "公历新年公共假期,重点影响跨年旅行、酒店、物流和年初家居焕新。",
|
||||
"scenes": ["跨年旅行", "酒店住宿", "年初焕新"],
|
||||
"products": ["酒店布草", "基础床品", "浴巾", "收纳软品"],
|
||||
"design": ["使用新年焕新而非单一宗教表达", "按南北半球季节调整材质"],
|
||||
"avoid": ["南半球正值夏季,不要统一推保暖产品"],
|
||||
},
|
||||
"super-bowl": {
|
||||
"title": "超级碗观赛消费节点", "relevance": "high", "decision": "值得场景备货",
|
||||
"what": "美国职业橄榄球年度决赛,带动家庭观赛、聚会、酒店和城市活动消费。",
|
||||
"scenes": ["家庭观赛", "朋友聚会", "酒店与城市活动"],
|
||||
"products": ["沙发盖毯", "靠垫", "坐垫", "酒吧与酒店布草"],
|
||||
"design": ["使用通用运动与城市氛围", "优先开发可快速换色的软装组合"],
|
||||
"avoid": ["球队名称、赛事标志和球员肖像涉及知识产权,未经授权不要使用"],
|
||||
},
|
||||
"la28": {
|
||||
"title": "洛杉矶 2028 奥运消费周期", "relevance": "high", "decision": "提前规划",
|
||||
"what": "大型国际体育赛事周期,机会集中在酒店、城市旅行、团体接待和跨境礼赠。",
|
||||
"scenes": ["酒店升级", "城市旅行", "团体接待"],
|
||||
"products": ["酒店床品", "功能毛巾", "旅行毯", "团体礼赠"],
|
||||
"design": ["使用通用运动、城市和多文化表达", "优先开发耐洗与快干材料"],
|
||||
"avoid": ["奥运标志、会徽和官方称谓受保护,营销前先做授权核验"],
|
||||
},
|
||||
"mountain-day": {
|
||||
"title": "日本山之日", "relevance": "low", "decision": "以运营排班为主",
|
||||
"what": "日本的国民祝日,鼓励亲近山林;对家纺更接近夏季出行与客服排班节点。",
|
||||
"scenes": ["户外出行", "短途旅行"],
|
||||
"products": ["便携毯", "快干毛巾"],
|
||||
"design": ["采用自然、山林和清爽材质表达"],
|
||||
"avoid": ["缺少专项消费证据时不要单独大规模压货"],
|
||||
},
|
||||
"national:ID": {
|
||||
"title": "印度尼西亚独立日", "relevance": "medium", "decision": "按平台活动判断",
|
||||
"what": "8 月 17 日是印度尼西亚独立日,全国会举行社区庆祝和红白主题活动,也是 Shopee、Tokopedia、Lazada 与 TikTok Shop 常见的 8.17 营销节点。",
|
||||
"scenes": ["社区庆祝", "家庭布置", "平台 8.17 活动"],
|
||||
"products": ["红白软装", "轻薄床品", "快干毛巾", "户外坐垫"],
|
||||
"design": ["优先使用当地语言与红白生活方式场景", "热带市场突出轻薄、快干和易清洗"],
|
||||
"avoid": ["国旗与国家象征的使用需尊重当地规范,不要把政治符号随意商品化"],
|
||||
},
|
||||
"national:PH": {
|
||||
"title": "菲律宾国家英雄日", "relevance": "low", "decision": "以排班与克制表达为主",
|
||||
"what": "菲律宾每年 8 月最后一个星期一纪念国家英雄,属于公共假日,但不是应默认建立家纺主题库存的强消费节。",
|
||||
"scenes": ["公共休假", "家庭出行", "物流与客服排班"],
|
||||
"products": [],
|
||||
"design": ["如参与平台活动,使用尊重历史与本地文化的克制表达"],
|
||||
"avoid": ["不要为了促销把英雄纪念语境包装成娱乐化主题"],
|
||||
},
|
||||
"national:US": {
|
||||
"title": "美国独立日", "relevance": "high", "decision": "值得场景备货",
|
||||
"what": "7 月 4 日是美国全国性公共假日,家庭聚会、户外活动、旅行和季中促销会共同带动家居与户外纺织需求。",
|
||||
"scenes": ["户外聚会", "家庭烧烤", "夏季旅行", "季中促销"],
|
||||
"products": ["户外毯", "野餐布", "沙发盖毯", "厨房毛巾"],
|
||||
"design": ["使用夏季户外与家庭聚会场景", "平台素材需提前核查广告与配送截止日"],
|
||||
"avoid": ["国旗图形、军政元素和品牌联名不得未经核验直接使用"],
|
||||
},
|
||||
"national:CN": {
|
||||
"title": "中国国庆假期", "relevance": "medium", "decision": "按内销与出行场景判断",
|
||||
"what": "中国国庆假期会影响国内生产、物流、客服和旅行消费节奏,跨境卖家还需提前安排工厂放假前的交付与补货。",
|
||||
"scenes": ["工厂与物流放假", "旅行住宿", "家庭换新"],
|
||||
"products": ["酒店布草", "旅行用品", "秋冬基础床品"],
|
||||
"design": ["重点表达秋季换新与旅行住宿", "跨境订单先锁定工厂排期"],
|
||||
"avoid": ["不要把国内假期误当作海外平台的统一消费节"],
|
||||
},
|
||||
"national": {
|
||||
"title": "国家纪念与公共假日", "relevance": "low", "decision": "先核对当地消费证据",
|
||||
"what": "国家纪念日、独立日或共和国日首先影响公共休假、物流和本地广告语境;没有平台活动或消费数据时不应自动转成备货机会。",
|
||||
"scenes": ["公共休假", "物流排班", "本地广告语境"],
|
||||
"products": [],
|
||||
"design": ["只在确认当地平台活动后制作本地化素材"],
|
||||
"avoid": ["国旗、国徽和政治符号需核验使用规范;不要批量套模板或制造主题库存"],
|
||||
},
|
||||
"memorial": {
|
||||
"title": "纪念日与悼念节点", "relevance": "low", "decision": "仅做排班与克制陈列",
|
||||
"what": "以纪念、悼念或宗教追思为主的公共节点,通常不适合强促销。",
|
||||
"scenes": ["公共休假", "克制陈列"],
|
||||
"products": [], "design": ["使用低饱和与克制表达"],
|
||||
"avoid": ["不要把悼念节点包装成热闹促销节"],
|
||||
},
|
||||
"public-holiday": {
|
||||
"title": "地区公共假日", "relevance": "low", "decision": "仅用于运营排班",
|
||||
"what": "该市场的法定或地方公共假日,首先影响物流、仓储、客服和门店营业。",
|
||||
"scenes": ["物流排班", "客服安排"],
|
||||
"products": [], "design": [],
|
||||
"avoid": ["没有明确消费证据时,不应为了日历数量制造专项备货建议"],
|
||||
},
|
||||
"st-stephens-day": {
|
||||
"title": "圣斯蒂芬节", "relevance": "low", "decision": "仅用于运营排班",
|
||||
"what": "12 月 26 日在欧洲、加拿大等市场使用不同当地名称庆祝或放假,部分市场也称圣诞次日或节礼日;主要影响物流、客服和门店营业,不等同于统一消费节。",
|
||||
"scenes": ["公共休假", "物流排班", "节后门店营业"],
|
||||
"products": [], "design": [],
|
||||
"avoid": ["不要把各国同日名称差异拆成多个节日,也不要在没有平台活动证据时专项备货"],
|
||||
},
|
||||
}
|
||||
|
||||
GLOBAL_AGGREGATE_KEYS = {
|
||||
"black-friday", "cyber-monday", "winter-holiday", "christmas", "boxing-day",
|
||||
"lunar-new-year", "eid-fitr", "eid-adha", "easter", "assumption-day", "new-year", "st-stephens-day",
|
||||
}
|
||||
|
||||
FESTIVAL_ENGLISH_TITLES = {
|
||||
"back-to-school": "Back to School",
|
||||
"black-friday": "Black Friday",
|
||||
"boxing-day": "Boxing Day",
|
||||
"christmas": "Christmas Day",
|
||||
"cyber-monday": "Cyber Monday",
|
||||
"easter": "Easter",
|
||||
"eid-adha": "Eid al-Adha",
|
||||
"eid-fitr": "Eid al-Fitr",
|
||||
"lunar-new-year": "Lunar New Year",
|
||||
"new-year": "New Year's Day",
|
||||
"singles-day": "Singles' Day",
|
||||
"st-stephens-day": "St. Stephen's Day",
|
||||
"super-bowl": "Super Bowl",
|
||||
"thanksgiving": "Thanksgiving Day",
|
||||
"winter-holiday": "Holiday Season",
|
||||
}
|
||||
|
||||
GENERIC_HOLIDAY_TITLES = {"国家纪念与公共假日", "地区公共假日"}
|
||||
HOLIDAY_TITLE_ALIASES = {"五一劳动节": "劳动节"}
|
||||
|
||||
|
||||
def _normalized_name(value: str) -> str:
|
||||
text = value.lower().replace("’", "'")
|
||||
text = re.sub(r"\([^)]*\)", " ", text)
|
||||
text = re.sub(r"\b(observed|substitute day|tentative date)\b", " ", text)
|
||||
return " ".join(re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text).split())
|
||||
|
||||
|
||||
def festival_key(name: str, source_id: str = "", country_code: str = "") -> str:
|
||||
lower = _normalized_name(name)
|
||||
if source_id in ("calendar_uk_school_terms", "calendar_de_school_holidays"):
|
||||
return "back-to-school"
|
||||
if country_code == "JP" and "labour thanksgiving" in lower:
|
||||
return "public-holiday:JP:labour-thanksgiving"
|
||||
if source_id == "calendar_tmall" or "双十一" in lower:
|
||||
return "singles-day"
|
||||
if any(token in lower for token in (
|
||||
"back to school", "back to college", "开学季", "返校季", "新学期", "新学年",
|
||||
"学生住房", "大学宿舍", "独居搬家", "学生住宿",
|
||||
)):
|
||||
return "back-to-school"
|
||||
if "black friday" in lower or "黑色星期五" in lower:
|
||||
return "black-friday"
|
||||
if "cyber monday" in lower or "网络星期一" in lower:
|
||||
return "cyber-monday"
|
||||
if "winter holiday" in lower or "冬季假日消费季" in lower:
|
||||
return "winter-holiday"
|
||||
if source_id == "calendar_nfl" or "super bowl" in lower or "超级碗" in lower:
|
||||
return "super-bowl"
|
||||
if source_id == "calendar_la28" or "la28" in lower or "olympic" in lower:
|
||||
return "la28"
|
||||
if any(token in lower for token in ("chinese new year", "lunar new year", "spring festival", "春节", "农历新年")):
|
||||
return "lunar-new-year"
|
||||
if "st stephen s day" in lower or "saint stephen s day" in lower or "圣斯蒂芬" in lower:
|
||||
return "st-stephens-day"
|
||||
if "boxing day" in lower or "节礼日" in lower:
|
||||
return "boxing-day"
|
||||
if "christmas" in lower or "圣诞" in lower:
|
||||
return "christmas"
|
||||
if country_code in ("US", "CA") and ("thanksgiving" in lower or "感恩节" in lower):
|
||||
return "thanksgiving"
|
||||
if "assumption" in lower or "圣母升天" in lower or lower == "假设":
|
||||
return "assumption-day"
|
||||
if "mountain day" in lower or "山之日" in lower:
|
||||
return "mountain-day"
|
||||
if any(token in lower for token in ("eid al fitr", "eid ul fitr", "hari raya puasa", "开斋")):
|
||||
return "eid-fitr"
|
||||
if any(token in lower for token in ("eid al adha", "eid ul adha", "宰牲")):
|
||||
return "eid-adha"
|
||||
if any(token in lower for token in ("easter", "good friday", "pentecost", "whit monday", "复活节", "耶稣受难")):
|
||||
return "easter"
|
||||
if any(token in lower for token in ("new year's day", "new year holiday", "元旦", "新年假期")):
|
||||
return "new-year"
|
||||
if any(token in lower for token in ("memorial", "remembrance", "all souls", "追悼", "纪念")):
|
||||
return "memorial"
|
||||
if any(token in lower for token in ("national", "independence", "republic", "constitution", "liberation", "victory", "国庆", "独立")):
|
||||
return f"national:{country_code}" if country_code else "national"
|
||||
return f"public-holiday:{country_code}:{lower or 'unknown'}"
|
||||
|
||||
|
||||
def calendar_event_type(name: str, source_id: str = "") -> str:
|
||||
key = festival_key(name, source_id)
|
||||
if key in ("back-to-school", "black-friday", "cyber-monday", "singles-day", "winter-holiday"):
|
||||
return "retail"
|
||||
if key in ("super-bowl", "la28"):
|
||||
return "sport"
|
||||
if key in ("eid-fitr", "eid-adha") or source_id == "calendar_aladhan":
|
||||
return "islamic"
|
||||
if key == "lunar-new-year":
|
||||
return "lunar-new-year"
|
||||
if key in ("christmas", "boxing-day", "thanksgiving"):
|
||||
return "christmas"
|
||||
if key == "easter":
|
||||
return "easter"
|
||||
if key == "national" or key.startswith("national:"):
|
||||
return "national"
|
||||
if key == "memorial":
|
||||
return "memorial"
|
||||
return "public-holiday"
|
||||
|
||||
|
||||
def holiday_title(local_name: str, english_name: str, country_code: str) -> str:
|
||||
translated = HOLIDAY_TRANSLATIONS.get(english_name.lower())
|
||||
if translated:
|
||||
return translated
|
||||
if country_code in ("CN", "JP", "KR") and local_name:
|
||||
return local_name
|
||||
return local_name or english_name or "公共假日"
|
||||
|
||||
|
||||
def platform_family(platform: str) -> str:
|
||||
for family in PLATFORM_PLAYBOOKS:
|
||||
if platform == family or platform.startswith(f"{family} "):
|
||||
return family
|
||||
return platform
|
||||
|
||||
|
||||
def platform_actions(platforms: list[str]) -> list[dict[str, str]]:
|
||||
actions: list[dict[str, str]] = []
|
||||
for platform in platforms:
|
||||
family = platform_family(platform)
|
||||
action = PLATFORM_PLAYBOOKS.get(family)
|
||||
if action and not any(row["platform"] == family for row in actions):
|
||||
actions.append({"platform": family, "action": action})
|
||||
return actions
|
||||
|
||||
|
||||
def calendar_culture(country_code: str, event_type: str) -> tuple[str, str, str]:
|
||||
if event_type == "islamic":
|
||||
return "中东及穆斯林市场", "islamic", "家庭团聚、礼赠与夜间消费"
|
||||
if event_type == "sport":
|
||||
return "全球体育消费", "sport", "观赛、旅行与酒店场景"
|
||||
if event_type == "retail":
|
||||
return COUNTRY_MARKETS.get(country_code, ("全球电商", "retail"))[0], "retail", "价格、流量与履约效率"
|
||||
market_group, culture_key = COUNTRY_MARKETS.get(country_code, ("全球", "central-europe"))
|
||||
return market_group, culture_key, "按当地文化、季节和公共假期安排运营"
|
||||
|
||||
|
||||
def profile_for(key: str) -> dict[str, Any]:
|
||||
if key.startswith("public-holiday:"):
|
||||
return FESTIVAL_PROFILES["public-holiday"]
|
||||
if key.startswith("national:") and key not in FESTIVAL_PROFILES:
|
||||
return FESTIVAL_PROFILES["national"]
|
||||
return FESTIVAL_PROFILES.get(key, FESTIVAL_PROFILES["public-holiday"])
|
||||
|
||||
|
||||
def profile_for_market(key: str, country_code: str) -> dict[str, Any]:
|
||||
profile = dict(profile_for(key))
|
||||
if key != "back-to-school":
|
||||
return profile
|
||||
overrides = {
|
||||
"GB": {
|
||||
"title": "英国秋季返校与学生住宿采购季",
|
||||
"what": "英国各地区秋季学期开学时间不完全一致,采购机会集中在学生住宿、耐洗床品、浴巾和小空间软装;具体日期按政府地区校历核对。",
|
||||
"products": ["单人床品套装", "床垫保护套", "耐洗浴巾", "宿舍收纳软品"],
|
||||
"design": ["突出耐洗、易护理和小空间收纳", "按英国单人床尺寸与目标学校住宿要求核对规格"],
|
||||
},
|
||||
"DE": {
|
||||
"title": "德国夏末返校与学生住宿采购季",
|
||||
"what": "德国各州开学时间不同,机会集中在学生宿舍、租住公寓和耐用基础床品;具体州别日期以德国教育部长联席会议校历为准。",
|
||||
"products": ["单人被套与枕套", "床垫保护套", "耐洗毛巾", "中性色靠垫与收纳软品"],
|
||||
"design": ["优先低饱和中性色与耐久材质", "商品页标清厘米尺寸、洗护温度和材料认证"],
|
||||
},
|
||||
"FR": {
|
||||
"title": "法国新学年返校与学生住房采购季",
|
||||
"what": "法国返校日按学年与大区安排,学生住房采购集中在八月下旬至九月;具体日期以法国教育部大区校历为准。",
|
||||
"products": ["单人被套与床单", "浴巾", "小户型窗帘", "靠垫与收纳软品"],
|
||||
"design": ["使用克制的低饱和色与现代图案", "法语商品页标清厘米尺寸、材质和洗护方式"],
|
||||
},
|
||||
"JP": {
|
||||
"title": "日本新学期与独居搬家采购季",
|
||||
"what": "日本多数学校四月进入新学年,同时叠加大学入学、就职和独居搬家;具体日期由学校与地方教育委员会确定。",
|
||||
"products": ["日式单人寝具", "薄被与床垫保护套", "毛巾套装", "遮光窗帘与收纳软品"],
|
||||
"design": ["控制体积、突出易收纳与易护理", "按日本单人床和本地厘米规格核对尺寸"],
|
||||
},
|
||||
"CA": {
|
||||
"title": "加拿大返校与大学宿舍采购季",
|
||||
"what": "加拿大返校日期按省、学区和学校安排,主要采购窗口集中在八月至九月;节点以安大略省政府校历作示例并要求复核目标省。",
|
||||
"products": ["保暖单人床品", "床垫保护套", "厚浴巾", "宿舍收纳与盖毯"],
|
||||
"design": ["兼顾早秋温差和冬季保暖", "按目标学校宿舍床型核对 Twin 与 Twin XL"],
|
||||
},
|
||||
"AU": {
|
||||
"title": "澳大利亚新学年与学生住宿采购季",
|
||||
"what": "澳大利亚各州第一学期通常在一月至二月开始,日期由州教育部门确定;采购窗口与北半球返校季不同。",
|
||||
"products": ["透气单人床品", "轻薄被", "浴巾", "遮光防晒窗帘与学生公寓软装"],
|
||||
"design": ["按南半球夏季强调透气、凉感和防晒", "按目标州校历与当地厘米规格核对商品信息"],
|
||||
},
|
||||
}
|
||||
profile.update(overrides.get(country_code, {}))
|
||||
return profile
|
||||
|
||||
|
||||
def calendar_opportunity(name: str, source_id: str = "") -> str:
|
||||
profile = profile_for(festival_key(name, source_id))
|
||||
products = profile.get("products") or []
|
||||
if products:
|
||||
return f"{profile['decision']}:优先评估{'、'.join(products[:3])},并按平台入仓时点倒排生产。"
|
||||
return "以物流、客服和仓储排班为主;没有明确消费证据时不建议建立专项库存。"
|
||||
|
||||
|
||||
def _planning_stage(now: date, start: date, end: date, stocking_start: date, stocking_end: date) -> tuple[str, str]:
|
||||
if now > end:
|
||||
return "今年节点已结束", "复盘销量、退货和库存,把有效款加入下一年度计划。"
|
||||
if start <= now <= end:
|
||||
return "今年备货已结束", "当前只做履约、客服和活动复盘,不再临时追加大批库存。"
|
||||
if now > stocking_end:
|
||||
return "营销执行期", "库存不再大幅追加,重点完成素材、定价、客服和履约检查。"
|
||||
if now >= stocking_start:
|
||||
return "备货窗口", "立即确认订单、生产交期、国际物流和平台最晚入仓日期。"
|
||||
return "规划期", "先确定目标国家、平台和产品,再安排打样、报价与生产计划。"
|
||||
|
||||
|
||||
def enrich_calendar_item(item: dict[str, Any], now: date, country_code: str, poster_for: Callable[..., dict[str, str]]) -> dict[str, Any]:
|
||||
english_name = str(item.get("title_english") or "").strip()
|
||||
local_name = str(item.get("title_original") or "").strip()
|
||||
combined_name = local_name if country_code in ("AU", "GB", "NZ", "CA") and _normalized_name(local_name) == "boxing day" else english_name
|
||||
combined_name = combined_name or " ".join(filter(None, (str(item.get("title") or ""), local_name)))
|
||||
source_id = str(item.get("source_id") or "")
|
||||
key = festival_key(combined_name, source_id, country_code)
|
||||
profile = profile_for_market(key, country_code)
|
||||
event_type = calendar_event_type(combined_name, source_id)
|
||||
market_group, culture_key, cultural_note = calendar_culture(country_code, event_type)
|
||||
country_detail = COUNTRY_DETAILS.get(country_code, (item.get("region") or "全球", market_group, culture_key, [], []))
|
||||
start = date.fromisoformat(str(item["start_date"])[:10])
|
||||
end = date.fromisoformat(str(item.get("end_date") or item["start_date"])[:10])
|
||||
lead_days = {
|
||||
"retail": 165, "sport": 180, "christmas": 150, "lunar-new-year": 165,
|
||||
"islamic": 120, "national": 100, "easter": 90, "memorial": 60,
|
||||
"public-holiday": 75,
|
||||
}[event_type]
|
||||
stocking_start = start - timedelta(days=lead_days)
|
||||
stocking_end = start - timedelta(days=max(21, min(45, lead_days // 3)))
|
||||
planning_stage, next_action = _planning_stage(now, start, end, stocking_start, stocking_end)
|
||||
relevance = str(profile["relevance"])
|
||||
impact = "高" if relevance == "high" else ("中" if relevance == "medium" else "低")
|
||||
priority_score = {"high": 120, "medium": 80, "low": 35}[relevance] + max(0, 180 - min((start - now).days, 180)) // 6
|
||||
item.update({
|
||||
"country_code": country_code,
|
||||
"market_group": market_group,
|
||||
"culture_key": culture_key,
|
||||
"cultural_note": cultural_note,
|
||||
"festival_key": key,
|
||||
"event_type": event_type,
|
||||
"impact": impact,
|
||||
"business_relevance": relevance,
|
||||
"decision_label": profile["decision"],
|
||||
"what_it_is": profile["what"],
|
||||
"consumption_scenes": list(profile.get("scenes") or []),
|
||||
"design_opportunities": list(profile.get("design") or []),
|
||||
"cultural_avoidances": list(profile.get("avoid") or []),
|
||||
"languages": list(country_detail[3]),
|
||||
"platforms": list(country_detail[4]),
|
||||
"lead_days": lead_days,
|
||||
"stocking_start": stocking_start.isoformat(),
|
||||
"stocking_end": stocking_end.isoformat(),
|
||||
"planning_stage": planning_stage,
|
||||
"next_action": next_action,
|
||||
"days_until": (start - now).days,
|
||||
"priority_score": priority_score,
|
||||
"product_suggestions": list(profile.get("products") or []),
|
||||
"analysis_basis": "官方日期 + 系统经营分析",
|
||||
})
|
||||
item["poster"] = poster_for(str(item["id"]), "calendar", culture_key)
|
||||
return item
|
||||
|
||||
|
||||
def _dedupe(values: list[str]) -> list[str]:
|
||||
return list(dict.fromkeys(value for value in values if value))
|
||||
|
||||
|
||||
def culture_reference_url(title: str) -> str:
|
||||
"""国际访客默认使用条目质量更完整的中文 Wikipedia。"""
|
||||
return f"https://zh.wikipedia.org/wiki/{quote(title.replace(' ', '_'), safe='')}"
|
||||
|
||||
|
||||
def culture_reference_cn_url(title: str) -> str:
|
||||
"""中国大陆访客使用国内网络更易访问的百度百科。"""
|
||||
return f"https://baike.baidu.com/item/{quote(title, safe='')}"
|
||||
|
||||
|
||||
def visual_reference_url(title: str, country_details: list[dict[str, Any]]) -> str:
|
||||
"""图片入口只搜索节日本身,不复用来源页标题。"""
|
||||
terms = [title]
|
||||
for detail in country_details[:2]:
|
||||
terms.extend((str(detail.get("local_name") or ""), str(detail.get("country") or "")))
|
||||
query = " ".join(_dedupe(terms))
|
||||
return f"https://www.bing.com/images/search?q={quote(query, safe='')}&setlang=zh-hans"
|
||||
|
||||
|
||||
def calendar_group_identity(item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""同日同名的明确节日跨国家聚合;国家纪念类泛称仍按国家拆分。"""
|
||||
key = str(item.get("festival_key") or "public-holiday")
|
||||
title = HOLIDAY_TITLE_ALIASES.get(str(item.get("title") or "").strip(), str(item.get("title") or "").strip())
|
||||
if title and title not in GENERIC_HOLIDAY_TITLES and (key.startswith("public-holiday:") or key == "memorial"):
|
||||
return f"named-holiday:{_normalized_name(title)}", ""
|
||||
country_partition = "" if key in GLOBAL_AGGREGATE_KEYS else str(item.get("country_code") or item.get("market_area") or item.get("region") or "")
|
||||
return key, country_partition
|
||||
|
||||
|
||||
def calendar_group_date(item: dict[str, Any]) -> str:
|
||||
"""圣诞季共用一张决策卡,其他节日仍按实际日期聚合。"""
|
||||
start_date = str(item["start_date"])
|
||||
if item.get("festival_key") != "christmas":
|
||||
return start_date
|
||||
value = date.fromisoformat(start_date)
|
||||
season_year = value.year - 1 if value.month == 1 and value.day <= 10 else value.year
|
||||
return f"{season_year}-12-25"
|
||||
|
||||
|
||||
def christmas_date_label(value: str, primary_date: str) -> str:
|
||||
if value == primary_date:
|
||||
return "主要日期"
|
||||
month_day = value[5:]
|
||||
if month_day == "12-24":
|
||||
return "平安夜"
|
||||
if month_day in ("01-06", "01-07"):
|
||||
return "东正教传统日期"
|
||||
if month_day in ("12-26", "12-27", "12-28"):
|
||||
return "法定假日顺延"
|
||||
return "地区日期"
|
||||
|
||||
|
||||
def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_for: Callable[..., dict[str, str]]) -> list[dict[str, Any]]:
|
||||
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
|
||||
for item in items:
|
||||
group_key, country_partition = calendar_group_identity(item)
|
||||
grouped.setdefault((calendar_group_date(item), group_key, country_partition), []).append(item)
|
||||
|
||||
insights: list[dict[str, Any]] = []
|
||||
for (start_date, group_key, _), rows in grouped.items():
|
||||
rows = sorted(rows, key=lambda row: (str(row.get("start_date")) != start_date, str(row.get("start_date"))))
|
||||
row_keys = _dedupe([str(row.get("festival_key") or "public-holiday") for row in rows])
|
||||
key = row_keys[0] if len(row_keys) == 1 else f"public-holiday:{group_key.removeprefix('named-holiday:')}"
|
||||
profile = profile_for_market(key, str(rows[0].get("country_code") or ""))
|
||||
country_codes = _dedupe([str(row.get("country_code") or "") for row in rows])
|
||||
market_groups = _dedupe([str(row.get("market_group") or "") for row in rows])
|
||||
languages = _dedupe([value for row in rows for value in row.get("languages") or []])
|
||||
platforms = _dedupe([value for row in rows for value in row.get("platforms") or []])
|
||||
titles = [str(row.get("title") or "") for row in rows if row.get("title")]
|
||||
title = str(profile.get("title") or Counter(titles).most_common(1)[0][0])
|
||||
if key.startswith("public-holiday:") or key in ("national", "memorial"):
|
||||
title = Counter(titles).most_common(1)[0][0]
|
||||
english_titles = [str(row.get("title_english") or "").strip() for row in rows if row.get("title_english")]
|
||||
title_english = FESTIVAL_ENGLISH_TITLES.get(key, "")
|
||||
if not title_english and english_titles:
|
||||
title_english = Counter(english_titles).most_common(1)[0][0]
|
||||
if not title_english:
|
||||
original_titles = [str(row.get("title_original") or "").strip() for row in rows]
|
||||
title_english = next((value for value in original_titles if re.search(r"[A-Za-z]", value)), title)
|
||||
digest = hashlib.sha256(f"{start_date}|{key}|{'|'.join(country_codes)}".encode("utf-8")).hexdigest()[:14]
|
||||
official_sources = []
|
||||
seen_sources: set[tuple[str, str]] = set()
|
||||
for row in rows:
|
||||
source_pair = (str(row.get("source") or ""), str(row.get("source_url") or row.get("url") or ""))
|
||||
if source_pair in seen_sources:
|
||||
continue
|
||||
seen_sources.add(source_pair)
|
||||
official_sources.append({"name": source_pair[0], "url": source_pair[1]})
|
||||
relevance = str(profile["relevance"])
|
||||
if not profile.get("products"):
|
||||
platforms = []
|
||||
country_details_by_name: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
code = str(row.get("country_code") or "")
|
||||
raw_region = str(row.get("region") or "")
|
||||
country = COUNTRY_DETAILS.get(code, (raw_region,))[0] if code else raw_region
|
||||
if not code and ("市场" in country or "全球" in country):
|
||||
continue
|
||||
fallback_scope = "以来源覆盖范围为准" if "市场" in raw_region or "全球" in raw_region else "全国适用"
|
||||
local_name = str(row.get("title_original") or row.get("title") or "")
|
||||
local_language = " / ".join(row.get("languages") or [])
|
||||
if code == "ZA" and row.get("title_english") == "Day of Goodwill":
|
||||
local_name, local_language = "Day of Goodwill", "英语"
|
||||
mapped_local = LOCAL_HOLIDAY_NAMES.get(key, {}).get(code)
|
||||
if mapped_local:
|
||||
local_name, local_language = mapped_local
|
||||
detail = {
|
||||
"country": country,
|
||||
"country_code": code,
|
||||
"country_english": COUNTRY_ENGLISH_NAMES.get(code, code),
|
||||
"local_name": local_name,
|
||||
"local_language": local_language,
|
||||
"languages": list(row.get("languages") or []),
|
||||
"scope": str(row.get("country_scope") or fallback_scope),
|
||||
"areas": list(row.get("applicable_areas") or (["全国"] if fallback_scope == "全国适用" else [])),
|
||||
"dates": [str(row.get("start_date") or start_date)],
|
||||
}
|
||||
existing = country_details_by_name.get(country)
|
||||
if not existing:
|
||||
country_details_by_name[country] = detail
|
||||
continue
|
||||
existing["languages"] = _dedupe([*existing["languages"], *detail["languages"]])
|
||||
existing["areas"] = _dedupe([*existing["areas"], *detail["areas"]])
|
||||
existing["dates"] = _dedupe([*existing["dates"], *detail["dates"]])
|
||||
if "全国适用" in (existing["scope"], detail["scope"]):
|
||||
existing["scope"] = "全国适用"
|
||||
existing["areas"] = ["全国"]
|
||||
elif existing["areas"]:
|
||||
if country == "英国" and {"英格兰和威尔士", "苏格兰", "北爱尔兰"}.issubset(existing["areas"]):
|
||||
existing["scope"] = "英国全境(英格兰和威尔士、苏格兰、北爱尔兰)"
|
||||
else:
|
||||
existing["scope"] = f"仅适用于{'、'.join(existing['areas'])}"
|
||||
if not country_details_by_name:
|
||||
for code in FESTIVAL_TARGET_COUNTRIES.get(key, ()):
|
||||
country, target_market, _, target_languages, target_platforms = COUNTRY_DETAILS[code]
|
||||
local_name, local_language = LOCAL_HOLIDAY_NAMES[key][code]
|
||||
country_details_by_name[country] = {
|
||||
"country": country,
|
||||
"country_code": code,
|
||||
"country_english": COUNTRY_ENGLISH_NAMES[code],
|
||||
"local_name": local_name,
|
||||
"local_language": local_language,
|
||||
"languages": list(target_languages),
|
||||
"scope": "重点经营市场;日期与放假范围以当地官方公告为准",
|
||||
"areas": [],
|
||||
"dates": [start_date],
|
||||
}
|
||||
country_codes.append(code)
|
||||
market_groups.append(target_market)
|
||||
languages.extend(target_languages)
|
||||
platforms.extend(target_platforms)
|
||||
country_codes = _dedupe(country_codes)
|
||||
market_groups = _dedupe(market_groups)
|
||||
languages = _dedupe(languages)
|
||||
platforms = _dedupe(platforms)
|
||||
country_details = list(country_details_by_name.values())
|
||||
countries = list(country_details_by_name)
|
||||
if len(countries) == 1:
|
||||
region = countries[0]
|
||||
elif len(countries) > 1:
|
||||
region = f"{market_groups[0]}多国" if len(market_groups) == 1 else f"{len(countries)} 个国家/地区"
|
||||
else:
|
||||
region = market_groups[0] if market_groups else str(rows[0].get("region") or "全球")
|
||||
first = rows[0]
|
||||
related_dates = sorted(_dedupe([str(row.get("start_date") or start_date) for row in rows]))
|
||||
date_variants = []
|
||||
for value in related_dates:
|
||||
variant_rows = [row for row in rows if str(row.get("start_date") or "") == value]
|
||||
variant_countries = _dedupe([
|
||||
COUNTRY_DETAILS.get(str(row.get("country_code") or ""), (str(row.get("region") or ""),))[0]
|
||||
for row in variant_rows
|
||||
])
|
||||
date_variants.append({
|
||||
"date": value,
|
||||
"label": christmas_date_label(value, start_date) if key == "christmas" else "地区日期",
|
||||
"countries": variant_countries,
|
||||
})
|
||||
insight = {
|
||||
"id": f"calendar-insight-{digest}",
|
||||
"section": "calendar",
|
||||
"source_id": str(first.get("source_id") or ""),
|
||||
"source": official_sources[0]["name"] if official_sources else "权威日期来源",
|
||||
"authority": str(first.get("authority") or "权威日期已核验"),
|
||||
"region": region,
|
||||
"title": title,
|
||||
"title_english": title_english,
|
||||
"title_original": country_details[0]["local_name"] if country_details else "",
|
||||
"summary": str(profile["what"]),
|
||||
"what_it_is": str(profile["what"]),
|
||||
"url": str(first.get("url") or first.get("source_url") or ""),
|
||||
"source_url": str(first.get("source_url") or first.get("url") or ""),
|
||||
"official_sources": official_sources,
|
||||
"image": "",
|
||||
"verified_at": max(str(row.get("verified_at") or "") for row in rows),
|
||||
"change_status": "官方日期已核验",
|
||||
"confidence": "高" if official_sources else "中高",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date if key == "christmas" else max(str(row.get("end_date") or start_date) for row in rows),
|
||||
"related_dates": related_dates,
|
||||
"date_variants": date_variants,
|
||||
"date_variation_note": (
|
||||
"12月24日是平安夜;多数市场在12月25日庆祝圣诞节。部分东正教传统在次年1月6日或7日庆祝;周末产生的法定补休不代表庆祝日期改变。"
|
||||
if key == "christmas" else ""
|
||||
),
|
||||
"impact": "高" if relevance == "high" else ("中" if relevance == "medium" else "低"),
|
||||
"business_relevance": relevance,
|
||||
"decision_label": str(profile["decision"]),
|
||||
"opportunity": str(first.get("next_action") or ""),
|
||||
"next_action": str(first.get("next_action") or ""),
|
||||
"market_group": "、".join(market_groups),
|
||||
"affected_regions": market_groups,
|
||||
"affected_countries": countries,
|
||||
"affected_country_codes": country_codes,
|
||||
"country_details": country_details,
|
||||
"languages": languages,
|
||||
"platforms": platforms,
|
||||
"platform_actions": platform_actions(platforms),
|
||||
"country_count": len(countries),
|
||||
"festival_key": key,
|
||||
"event_type": str(first.get("event_type") or "public-holiday"),
|
||||
"culture_key": str(first.get("culture_key") or "central-europe"),
|
||||
"consumption_scenes": list(profile.get("scenes") or []),
|
||||
"product_suggestions": list(profile.get("products") or []),
|
||||
"design_opportunities": list(profile.get("design") or []),
|
||||
"cultural_avoidances": list(profile.get("avoid") or []),
|
||||
"stocking_start": min(str(row.get("stocking_start") or start_date) for row in rows),
|
||||
"stocking_end": max(str(row.get("stocking_end") or start_date) for row in rows),
|
||||
"planning_stage": str(first.get("planning_stage") or "规划期"),
|
||||
"days_until": (date.fromisoformat(start_date) - now).days,
|
||||
"priority_score": max(int(row.get("priority_score") or 0) for row in rows) + min(12, len(countries)),
|
||||
"analysis_basis": "事实层:官方日期与适用国家;分析层:家纺经营场景推断",
|
||||
"visual_reference_url": visual_reference_url(title, country_details),
|
||||
"culture_reference_url": culture_reference_url(title),
|
||||
"culture_reference_cn_url": culture_reference_cn_url(title),
|
||||
}
|
||||
translation = {
|
||||
key: first[key]
|
||||
for key in (
|
||||
"source_language", "needs_translation", "google_translation_supported",
|
||||
"translation_url", "translation_provider", "translation_fallback_url",
|
||||
"translation_fallback_provider", "translation_notice",
|
||||
)
|
||||
if key in first
|
||||
}
|
||||
insight.update(translation)
|
||||
insight["poster"] = poster_for(insight["id"], "calendar", insight["culture_key"])
|
||||
insights.append(insight)
|
||||
|
||||
return sorted(insights, key=lambda row: (row["start_date"], -int(row["priority_score"]), row["title"]))
|
||||
@@ -0,0 +1,655 @@
|
||||
#!/usr/bin/env python3
|
||||
"""展会情报聚合、报名入口、翻译与跨源去重。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if __package__:
|
||||
from .intelligence_web_translation import translation_fields
|
||||
from .update_textile_trends import load_zh_cache, save_zh_cache, translate_to_zh_cn
|
||||
else:
|
||||
from intelligence_web_translation import translation_fields
|
||||
from update_textile_trends import load_zh_cache, save_zh_cache, translate_to_zh_cn
|
||||
|
||||
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0 Safari/537.36"
|
||||
)
|
||||
TEXPERTISE_JS_URL = "https://eventsearch.messefrankfurt.com/assets/eventsearch.js"
|
||||
TEXPERTISE_API_URL = (
|
||||
"https://api.messefrankfurt.com/service/esb_api/event-service/"
|
||||
"api/2.1/public/event/searchMultiParam/en-GB"
|
||||
)
|
||||
UFI_SEARCH_URL = "https://www.ufi.org/search-approved-events/"
|
||||
INTERTEXTILE_HOME_ZH = "https://intertextile-shanghai-hometextiles-autumn.hk.messefrankfurt.com/shanghai/zh-cn/planning-preparation/visiting.html"
|
||||
INTERTEXTILE_HOME_VISITOR_ZH = "https://itsh.exporeg.cn/dingzhi/pc-registration-jiafang/#/jump?exhibitionId=2018497552125587458®isterType=visitor&languageType=cn®istrationSource=registration&tenantId=762803®isterLinkId=2018507440105332737&isOpenAdapt=yes&sourceCode=2608-2101"
|
||||
INTERTEXTILE_HOME_AUTUMN_ZH = "https://intertextile-shanghai-hometextiles-autumn.hk.messefrankfurt.com/shanghai/zh-cn.html"
|
||||
UFI_SECTORS = (
|
||||
"TextilesApparelFashion",
|
||||
"FurnitureInteriordesign",
|
||||
"PremiumHouseholdGiftsToys",
|
||||
)
|
||||
RELEVANCE_TERMS = (
|
||||
"textile", "fabric", "apparel", "fashion", "garment", "leather", "footwear", "shoe",
|
||||
"hosiery", "home", "interior", "furniture", "furnishing", "decor", "décor", "carpet",
|
||||
"floor", "design", "gift", "handicraft", "household", "bedding", "linen", "mattress",
|
||||
"curtain", "wallcovering", "baby", "sportswear", "accessories", "lifestyle", "weaving",
|
||||
)
|
||||
RELEVANCE_EVENT_NAMES = (
|
||||
"Zhejiang Export Fair (Vietnam)",
|
||||
"Texhibition",
|
||||
"ZUCHEX",
|
||||
"Egypt Stitch and Tex",
|
||||
"Feria Habitat Valencia",
|
||||
"ANAMOB",
|
||||
"China Yiwu International Commodities Fair-Export Goods Exhibition",
|
||||
"China International Occupational Safety & Health Goods Expo (CIOSH)",
|
||||
)
|
||||
EXCLUDED_TERMS = (
|
||||
"bathroom kitchen", "plumbing", "elderly health", "aquatherm", "halal showcase",
|
||||
"industrial fair", "factory fair", "ceramic", "fire-fighting", "hospital construction",
|
||||
)
|
||||
|
||||
|
||||
def make_session() -> requests.Session:
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=3,
|
||||
connect=3,
|
||||
read=3,
|
||||
backoff_factor=1.0,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset(("GET",)),
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
session.headers.update({
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
||||
})
|
||||
return session
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return " ".join((value or "").replace("\u00a0", " ").split())
|
||||
|
||||
|
||||
def secure_url(value: str, fallback: str) -> str:
|
||||
# BeautifulSoup 已经解码标准 HTML 实体;再次解码会把
|
||||
# `®isterType` 误判成无分号的 `®`,破坏真实报名参数。
|
||||
url = normalize_text(value)
|
||||
if url.startswith("//"):
|
||||
url = f"https:{url}"
|
||||
elif url.startswith("www."):
|
||||
url = f"https://{url}"
|
||||
elif url.startswith("http://"):
|
||||
url = f"https://{url[7:]}"
|
||||
return url if url.startswith("https://") else fallback
|
||||
|
||||
|
||||
def find_exhibition_link(result: Any, intent: str) -> str:
|
||||
if not result.raw:
|
||||
return ""
|
||||
try:
|
||||
soup = BeautifulSoup(result.raw, "html.parser")
|
||||
except Exception:
|
||||
return ""
|
||||
intent_tokens = {
|
||||
"exhibitor": ("register", "registration", "exhibit", "exhibitor", "book a stand", "参展", "报名", "申请展位"),
|
||||
"visitor": ("visit", "visitor", "ticket", "badge", "attend", "观众", "门票", "参观"),
|
||||
}[intent]
|
||||
candidates: list[tuple[int, str]] = []
|
||||
base_host = urlparse(result.final_url).netloc
|
||||
for node in soup.select("a[href]"):
|
||||
href = normalize_text(str(node.get("href") or ""))
|
||||
if not href or href.startswith(("#", "mailto:", "javascript:")):
|
||||
continue
|
||||
absolute_url = secure_url(urljoin(result.final_url, href), "")
|
||||
if not absolute_url:
|
||||
continue
|
||||
haystack = f"{normalize_text(node.get_text(' ', strip=True))} {href}".lower()
|
||||
score = sum(4 for token in intent_tokens if token in haystack)
|
||||
if not score:
|
||||
continue
|
||||
if urlparse(absolute_url).netloc == base_host:
|
||||
score += 2
|
||||
if any(token in haystack for token in ("login", "account", "privacy", "newsletter")):
|
||||
score -= 4
|
||||
candidates.append((score, absolute_url))
|
||||
return max(candidates, default=(0, ""), key=lambda row: row[0])[1]
|
||||
|
||||
|
||||
def exhibition_link_label(url: str, official_url: str, intent: str) -> str:
|
||||
normalized_url = (url or "").lower()
|
||||
if not url or url == official_url:
|
||||
return "官方展会页"
|
||||
if intent == "exhibitor":
|
||||
if any(token in normalized_url for token in ("register", "registration", "application", "apply", "book-a-stand", "booking")):
|
||||
return "官方报名"
|
||||
return "参展信息"
|
||||
if any(token in normalized_url for token in ("ticket", "badge", "register", "registration", "visitor-area")):
|
||||
return "观众登记"
|
||||
return "观展信息"
|
||||
|
||||
|
||||
def exhibition_focus(text: str) -> list[str]:
|
||||
lower = text.lower()
|
||||
focus: list[str] = []
|
||||
candidates = (
|
||||
(("bedding", "bed linen", "床品"), "床品与套件"),
|
||||
(("curtain", "window", "窗帘"), "窗帘与遮阳"),
|
||||
(("interior", "decor", "décor", "装饰"), "空间与装饰面料"),
|
||||
(("carpet", "floor", "rug", "地毯"), "地毯与地材"),
|
||||
(("hotel", "hospitality", "酒店"), "酒店与工程"),
|
||||
(("technical", "functional", "nonwoven", "功能"), "功能性材料"),
|
||||
(("sustainable", "recycled", "可持续"), "可持续材料"),
|
||||
(("gift", "礼品"), "家居礼赠"),
|
||||
(("furniture", "furnishing", "家具"), "家具与整体软装"),
|
||||
(("textile", "fabric", "apparel", "面料"), "纺织面料"),
|
||||
(("fashion", "garment", "服装"), "跨品类趋势"),
|
||||
)
|
||||
for tokens, label in candidates:
|
||||
if any(token in lower for token in tokens) and label not in focus:
|
||||
focus.append(label)
|
||||
return focus[:5] or ["家纺采购", "渠道对接"]
|
||||
|
||||
|
||||
def exhibition_checklist(days_until: int) -> list[str]:
|
||||
if days_until <= 7:
|
||||
return ["今年参展准备期已不足", "优先观展或线上联系主办方", "预约仍可联系的买家", "关注下一届参展申请"]
|
||||
if days_until <= 30:
|
||||
return ["参展准备窗口紧张", "优先核对观众登记", "预约重点买家", "复核签证、住宿和物流"]
|
||||
if days_until <= 90:
|
||||
return ["比较参展与观展方案", "预约重点买家", "完成样品与报价", "规划签证和差旅"]
|
||||
return ["评估展会与目标市场匹配度", "申请展位或早鸟门票", "规划新品与样品", "建立买家邀约名单"]
|
||||
|
||||
|
||||
def exhibition_action_status(days_until: int) -> tuple[str, str]:
|
||||
if days_until <= 7:
|
||||
return "仅建议观展或线上联系", "临近开展,不建议仓促参展;优先观展、预约买家或关注下一届。"
|
||||
if days_until <= 30:
|
||||
return "优先观展,谨慎临时参展", "参展准备时间紧张,先核对签证、样品、搭建与运输是否可完成。"
|
||||
if days_until <= 90:
|
||||
return "可观展,也可评估参展", "仍有时间准备样品、报价、买家邀约和差旅。"
|
||||
return "适合提前评估参展", "可比较展位、目标买家、物流成本和新品节奏后再报名。"
|
||||
|
||||
|
||||
def _curated_exhibitions(now: date, horizon: date, sources: Iterable[Any], results: dict[str, Any], base_item: Callable[..., dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for source in sources:
|
||||
if source.section != "exhibition" or source.extractor != "html" or not source.start_date:
|
||||
continue
|
||||
start = date.fromisoformat(source.start_date)
|
||||
end = date.fromisoformat(source.end_date)
|
||||
if end < now or start > horizon:
|
||||
continue
|
||||
result = results.get(source.id)
|
||||
if not result or not result.ok:
|
||||
continue
|
||||
item = base_item(source, result, "exhibition")
|
||||
official_url = secure_url(result.final_url or source.url, source.url)
|
||||
registration_url = find_exhibition_link(result, "exhibitor")
|
||||
visitor_url = find_exhibition_link(result, "visitor")
|
||||
if source.id == "expo_intertextile_home":
|
||||
official_url = INTERTEXTILE_HOME_ZH
|
||||
registration_url = INTERTEXTILE_HOME_VISITOR_ZH
|
||||
visitor_url = INTERTEXTILE_HOME_ZH
|
||||
days_until = (start - now).days
|
||||
action_status, action_reason = exhibition_action_status(days_until)
|
||||
item.update({
|
||||
"start_date": source.start_date,
|
||||
"end_date": source.end_date,
|
||||
"venue": source.venue,
|
||||
"opportunity": source.opportunity,
|
||||
"registration_url": registration_url or official_url,
|
||||
"visitor_url": visitor_url or official_url,
|
||||
"registration_mode": exhibition_link_label(registration_url, official_url, "exhibitor"),
|
||||
"visitor_mode": exhibition_link_label(visitor_url, official_url, "visitor"),
|
||||
"event_focus": exhibition_focus(f"{source.title_zh} {source.summary_zh} {source.opportunity}"),
|
||||
"trip_checklist": exhibition_checklist(days_until),
|
||||
"attendance_recommendation": action_status,
|
||||
"attendance_reason": action_reason,
|
||||
"days_until": days_until,
|
||||
})
|
||||
page_language = str(getattr(result, "page_language", "") or "")
|
||||
page_text = f"{getattr(result, 'title', '')} {getattr(result, 'description', '')}"
|
||||
item.update(translation_fields(official_url, page_language, page_text))
|
||||
item.update({
|
||||
"registration_translation_url": translation_fields(
|
||||
registration_url or official_url, page_language, page_text
|
||||
).get("translation_url", ""),
|
||||
"registration_translation_fallback_url": translation_fields(
|
||||
registration_url or official_url, page_language, page_text
|
||||
).get("translation_fallback_url", ""),
|
||||
"visitor_translation_url": translation_fields(
|
||||
visitor_url or official_url, page_language, page_text
|
||||
).get("translation_url", ""),
|
||||
"visitor_translation_fallback_url": translation_fields(
|
||||
visitor_url or official_url, page_language, page_text
|
||||
).get("translation_fallback_url", ""),
|
||||
})
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _extract_texpertise_api_key(session: requests.Session) -> str:
|
||||
response = session.get(TEXPERTISE_JS_URL, headers={"Referer": "https://texpertisenetwork.messefrankfurt.com/"}, timeout=(12, 45))
|
||||
response.raise_for_status()
|
||||
match = re.search(r'eventApikey".{0,500}?case"prd":return"([^"]+)"', response.text, re.S)
|
||||
if not match:
|
||||
raise ValueError("未能从 Messe Frankfurt 官方前端提取公开事件 API 凭据")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _infer_region(value: str) -> str:
|
||||
lower = value.lower()
|
||||
groups = (
|
||||
(("shanghai", "hangzhou", "shenzhen", "guangzhou", "yiwu", "ningbo", "shantou", "zhuji", "shaoxing"), "中国"),
|
||||
(("frankfurt", "offenbach", "hannover"), "德国"), (("salzburg",), "奥地利"),
|
||||
(("paris",), "法国"), (("milan",), "意大利"), (("istanbul", "konya"), "土耳其"),
|
||||
(("new york", "los angeles", "atlanta", "las vegas"), "美国"),
|
||||
(("tashkent",), "乌兹别克斯坦"), (("mumbai", "new delhi", "coimbatore"), "印度"),
|
||||
(("ho chi minh", "saigon", "hanoi"), "越南"), (("jakarta",), "印度尼西亚"),
|
||||
(("dubai", "sharjah"), "阿联酋"), (("riyadh",), "沙特阿拉伯"),
|
||||
(("karachi",), "巴基斯坦"), (("kuala lumpur",), "马来西亚"),
|
||||
(("amsterdam",), "荷兰"), (("warsaw",), "波兰"), (("valencia", "madrid", "barcelona"), "西班牙"),
|
||||
(("helsinki",), "芬兰"), (("zagreb",), "克罗地亚"), (("belgrade",), "塞尔维亚"),
|
||||
(("addis",), "埃塞俄比亚"), (("nairobi",), "肯尼亚"), (("mexico",), "墨西哥"),
|
||||
(("johannesburg",), "南非"), (("sao paulo",), "巴西"), (("cairo",), "埃及"),
|
||||
(("muscat",), "阿曼"), (("lisbon", "porto"), "葡萄牙"), (("athens", "kastoria"), "希腊"),
|
||||
)
|
||||
for tokens, region in groups:
|
||||
if any(token in lower for token in tokens):
|
||||
return region
|
||||
return "全球"
|
||||
|
||||
|
||||
def _dynamic_item(*, now: date, source: Any, result: Any, event_key: str, title: str, summary: str, start: date, end: date, venue: str, official_url: str, source_url: str, image: str, opportunity: str, make_id: Callable[..., str], poster_for: Callable[..., dict[str, str]]) -> dict[str, Any]:
|
||||
item_id = make_id("exhibition", source.id, event_key, start.isoformat())
|
||||
payload_hash = hashlib.sha256(
|
||||
json.dumps([title, summary, start.isoformat(), end.isoformat(), venue, official_url], ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest()
|
||||
days_until = (start - now).days
|
||||
action_status, action_reason = exhibition_action_status(days_until)
|
||||
item = {
|
||||
"id": item_id,
|
||||
"section": "exhibition",
|
||||
"source_id": source.id,
|
||||
"source": source.name,
|
||||
"authority": source.authority,
|
||||
"region": _infer_region(venue),
|
||||
"title": title,
|
||||
"title_original": title,
|
||||
"summary": summary,
|
||||
"summary_original": summary,
|
||||
"url": official_url,
|
||||
"source_url": source_url,
|
||||
"image": image,
|
||||
"poster": poster_for(item_id, "exhibition"),
|
||||
"verified_at": result.checked_at,
|
||||
"content_hash": payload_hash,
|
||||
"change_status": "官方目录实时同步",
|
||||
"confidence": "高",
|
||||
"start_date": start.isoformat(),
|
||||
"end_date": end.isoformat(),
|
||||
"venue": venue,
|
||||
"opportunity": opportunity,
|
||||
"registration_url": official_url,
|
||||
"visitor_url": official_url,
|
||||
"registration_mode": "官方展会页",
|
||||
"visitor_mode": "观展信息",
|
||||
"event_focus": exhibition_focus(f"{title} {summary}"),
|
||||
"trip_checklist": exhibition_checklist(days_until),
|
||||
"attendance_recommendation": action_status,
|
||||
"attendance_reason": action_reason,
|
||||
"days_until": days_until,
|
||||
}
|
||||
page_language = str(getattr(result, "page_language", "") or "")
|
||||
page_text = f"{title} {summary}"
|
||||
item.update(translation_fields(official_url, page_language, page_text))
|
||||
item.update({
|
||||
"registration_translation_url": item["translation_url"],
|
||||
"registration_translation_fallback_url": item["translation_fallback_url"],
|
||||
"visitor_translation_url": item["translation_url"],
|
||||
"visitor_translation_fallback_url": item["translation_fallback_url"],
|
||||
})
|
||||
return item
|
||||
|
||||
|
||||
def normalize_dynamic_exhibition_url(title: str, url: str) -> str:
|
||||
value = f"{title} {url}".lower()
|
||||
is_home_textile = any(marker in value for marker in ("家纺", "家用纺织", "home textile", "intertextilehome.com"))
|
||||
is_autumn = any(marker in value for marker in ("秋季", "秋冬", "autumn"))
|
||||
return INTERTEXTILE_HOME_AUTUMN_ZH if "intertextile" in value and is_home_textile and is_autumn else url
|
||||
|
||||
|
||||
def _texpertise_exhibitions(now: date, horizon: date, source: Any, result: Any, make_id: Callable[..., str], poster_for: Callable[..., dict[str, str]]) -> list[dict[str, Any]]:
|
||||
session = make_session()
|
||||
api_key = _extract_texpertise_api_key(session)
|
||||
response = session.get(
|
||||
TEXPERTISE_API_URL,
|
||||
headers={"apikey": api_key, "Accept": "application/json"},
|
||||
params={"cluster1": "Textiles & Textile Technologies"},
|
||||
timeout=(12, 45),
|
||||
)
|
||||
response.raise_for_status()
|
||||
rows = ((response.json() or {}).get("result") or {}).get("hits") or []
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
try:
|
||||
start = date.fromisoformat(str(row.get("startdate") or ""))
|
||||
end = date.fromisoformat(str(row.get("enddate") or row.get("startdate") or ""))
|
||||
except ValueError:
|
||||
continue
|
||||
if end < now or start > horizon or row.get("cancelled") == "Y":
|
||||
continue
|
||||
title = normalize_text(str(row.get("eventname") or ""))
|
||||
if not title:
|
||||
continue
|
||||
cluster = normalize_text(str(row.get("cluster_2") or row.get("clusterMarketing") or "纺织与家纺"))
|
||||
subtitle = normalize_text(str(row.get("subtitle") or ""))
|
||||
summary = subtitle or f"Messe Frankfurt Texpertise Network 官方收录的{cluster}专业展会。"
|
||||
venue = normalize_text(str(row.get("cityname") or "全球会展城市"))
|
||||
official_url = secure_url(str(row.get("interneturl") or ""), source.url)
|
||||
official_url = normalize_dynamic_exhibition_url(title, official_url)
|
||||
event_id = str(row.get("eventid") or f"{title}-{start.isoformat()}")
|
||||
image = f"https://www.messefrankfurt.com/content/dam/eventsearch/keyvisuals/{event_id}.webp.480.jpg"
|
||||
items.append(_dynamic_item(
|
||||
now=now,
|
||||
source=source,
|
||||
result=result,
|
||||
event_key=event_id,
|
||||
title=title,
|
||||
summary=summary,
|
||||
start=start,
|
||||
end=end,
|
||||
venue=venue,
|
||||
official_url=official_url,
|
||||
source_url=source.url,
|
||||
image=image,
|
||||
opportunity="对照目标市场和品类安排参展或观展,并提前准备买家邀约、样品、报价、签证与物流。",
|
||||
make_id=make_id,
|
||||
poster_for=poster_for,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
def _text_from_html(value: str) -> str:
|
||||
return normalize_text(BeautifulSoup(value, "html.parser").get_text(" ", strip=True))
|
||||
|
||||
|
||||
def _parse_ufi_listing(raw: str, sector: str) -> tuple[dict[str, dict[str, Any]], int]:
|
||||
items: dict[str, dict[str, Any]] = {}
|
||||
for href, block in re.findall(r'<a href="([^"]*single-approved-event[^\"]+)" class="approved_events__block">(.*?)</a>', raw, re.S | re.I):
|
||||
title_match = re.search(r'<p class="title h3">(.*?)</p>', block, re.S | re.I)
|
||||
description_match = re.search(r'<div class="description">(.*?)</div>', block, re.S | re.I)
|
||||
image_match = re.search(r'<img[^>]+src="([^"]+)"', block, re.S | re.I)
|
||||
title = _text_from_html(title_match.group(1)) if title_match else ""
|
||||
description = _text_from_html(description_match.group(1)) if description_match else ""
|
||||
haystack = f"{title} {description}".lower()
|
||||
if not title or (not any(token in haystack for token in RELEVANCE_TERMS) and title not in RELEVANCE_EVENT_NAMES):
|
||||
continue
|
||||
if any(token in haystack for token in EXCLUDED_TERMS):
|
||||
continue
|
||||
items[href] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"image": secure_url(image_match.group(1), "") if image_match else "",
|
||||
"sectors": {sector},
|
||||
}
|
||||
nav = re.search(r'id="xyz_pagination"[^>]+data-current="\d+" data-max="(\d+)"', raw)
|
||||
return items, int(nav.group(1)) if nav else 1
|
||||
|
||||
|
||||
def _fetch_ufi_detail(pair: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], str]:
|
||||
href, meta = pair
|
||||
session = make_session()
|
||||
response = session.get(href, timeout=(12, 45))
|
||||
response.raise_for_status()
|
||||
return href, meta, response.text
|
||||
|
||||
|
||||
def _ufi_exhibitions(now: date, horizon: date, source: Any, result: Any, make_id: Callable[..., str], poster_for: Callable[..., dict[str, str]]) -> list[dict[str, Any]]:
|
||||
session = make_session()
|
||||
listings: dict[str, dict[str, Any]] = {}
|
||||
for sector in UFI_SECTORS:
|
||||
first_raw = result.raw.decode("utf-8", errors="ignore") if sector == UFI_SECTORS[0] else session.get(
|
||||
UFI_SEARCH_URL, params={"business_sector": sector}, timeout=(12, 45)
|
||||
).text
|
||||
first_items, max_page = _parse_ufi_listing(first_raw, sector)
|
||||
for href, meta in first_items.items():
|
||||
existing = listings.setdefault(href, meta)
|
||||
existing["sectors"].add(sector)
|
||||
for page in range(2, max_page + 1):
|
||||
response = session.get(
|
||||
UFI_SEARCH_URL,
|
||||
params={"business_sector": sector, "current_page": page},
|
||||
timeout=(12, 45),
|
||||
)
|
||||
response.raise_for_status()
|
||||
page_items, _ = _parse_ufi_listing(response.text, sector)
|
||||
for href, meta in page_items.items():
|
||||
existing = listings.setdefault(href, meta)
|
||||
existing["sectors"].add(sector)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
details = executor.map(_fetch_ufi_detail, listings.items())
|
||||
for href, meta, raw in details:
|
||||
article_start = raw.find('<div class="m_article__content">')
|
||||
article_end = raw.find("</main>", article_start)
|
||||
article = raw[article_start:article_end] if article_start >= 0 else raw
|
||||
official_url = ""
|
||||
for url, anchor_text in re.findall(r'<a\s+href="([^"]+)"[^>]*>(.*?)</a>', article, re.S | re.I):
|
||||
if "view website" in _text_from_html(anchor_text).lower():
|
||||
official_url = secure_url(url, href)
|
||||
break
|
||||
official_url = official_url or href
|
||||
for block in re.findall(r'<li class="sessions__item">(.*?)</li>', raw, re.S | re.I):
|
||||
def field(class_name: str) -> str:
|
||||
match = re.search(r'class="' + re.escape(class_name) + r'"[^>]*>(.*?)</', block, re.S | re.I)
|
||||
return _text_from_html(match.group(1)) if match else ""
|
||||
|
||||
try:
|
||||
start = datetime.strptime(field("start_date"), "%d %B %Y").date()
|
||||
end_text = field("end_date")
|
||||
end = datetime.strptime(end_text, "%d %B %Y").date() if end_text else start
|
||||
except ValueError:
|
||||
continue
|
||||
if end < start or end < now or start > horizon:
|
||||
continue
|
||||
venue = field("venue_name") or "UFI 公布会场"
|
||||
summary = meta["description"] or "UFI 认证并公布未来场次、会场与主办官网的专业贸易展会。"
|
||||
items.append(_dynamic_item(
|
||||
now=now,
|
||||
source=source,
|
||||
result=result,
|
||||
event_key=urlparse(href).query or meta["title"],
|
||||
title=meta["title"],
|
||||
summary=summary,
|
||||
start=start,
|
||||
end=end,
|
||||
venue=venue,
|
||||
official_url=official_url,
|
||||
source_url=href,
|
||||
image=meta["image"],
|
||||
opportunity="先用 UFI 认证核验展会资质,再通过主办官网完成报名、观展、签证、住宿与买家会面安排。",
|
||||
make_id=make_id,
|
||||
poster_for=poster_for,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
def _event_alias(item: dict[str, Any]) -> str:
|
||||
value = f"{item.get('title_original') or item.get('title') or ''} {item.get('venue') or ''}".lower()
|
||||
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||
aliases = (
|
||||
("intertextile shanghai home", "intertextile-home"),
|
||||
("intertextile shanghai apparel", "intertextile-apparel"),
|
||||
("home textiles sourcing", "home-textiles-sourcing"),
|
||||
("texworld paris", "texworld-paris"),
|
||||
("heimtextil" , "heimtextil"),
|
||||
("techtextil" , "techtextil"),
|
||||
("premiere vision", "premiere-vision"),
|
||||
("maison objet", "maison-objet"),
|
||||
)
|
||||
if "texworld" in normalized and "paris" in normalized:
|
||||
return "texworld-paris"
|
||||
if normalized.strip().startswith("fim ") and "valencia" in normalized:
|
||||
return "feria-habitat-valencia"
|
||||
if "feria habitat valencia" in normalized:
|
||||
return "feria-habitat-valencia"
|
||||
for token, alias in aliases:
|
||||
if token in normalized:
|
||||
if alias in ("heimtextil", "techtextil"):
|
||||
venue = normalize_text(str(item.get("venue") or "")).lower()
|
||||
city = "frankfurt" if "frankfurt" in venue else venue.split(",")[0]
|
||||
return f"{alias}-{city}"
|
||||
return alias
|
||||
normalized = re.sub(r"\b(autumn|spring|summer|winter|edition|international|trade|fair|exhibition|licensed)\b", " ", normalized)
|
||||
return re.sub(r"[^a-z0-9]+", " ", normalized).strip()
|
||||
|
||||
|
||||
def dedupe_exhibitions(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
output: list[dict[str, Any]] = []
|
||||
source_priority = {"expo_texpertise_network": 1, "expo_ufi_approved": 2}
|
||||
for item in sorted(items, key=lambda row: (row["start_date"], source_priority.get(row["source_id"], 0), row["title"])):
|
||||
alias = _event_alias(item)
|
||||
duplicate = False
|
||||
for existing in output:
|
||||
if existing["start_date"] != item["start_date"]:
|
||||
continue
|
||||
existing_alias = _event_alias(existing)
|
||||
if alias == existing_alias or SequenceMatcher(None, alias, existing_alias).ratio() >= 0.84:
|
||||
duplicate = True
|
||||
break
|
||||
if not duplicate:
|
||||
output.append(item)
|
||||
return sorted(output, key=lambda row: (row["start_date"], row["title"]))
|
||||
|
||||
|
||||
def build_exhibitions(now: date, horizon: date, sources: Iterable[Any], results: dict[str, Any], base_item: Callable[..., dict[str, Any]], make_id: Callable[..., str], poster_for: Callable[..., dict[str, str]]) -> list[dict[str, Any]]:
|
||||
source_list = list(sources)
|
||||
items = _curated_exhibitions(now, horizon, source_list, results, base_item)
|
||||
by_id = {source.id: source for source in source_list}
|
||||
texpertise = results.get("expo_texpertise_network")
|
||||
if texpertise and texpertise.ok:
|
||||
items.extend(_texpertise_exhibitions(now, horizon, by_id["expo_texpertise_network"], texpertise, make_id, poster_for))
|
||||
ufi = results.get("expo_ufi_approved")
|
||||
if ufi and ufi.ok:
|
||||
items.extend(_ufi_exhibitions(now, horizon, by_id["expo_ufi_approved"], ufi, make_id, poster_for))
|
||||
deduped = dedupe_exhibitions(items)
|
||||
relevance_order = {"核心家纺展": 0, "纺织面料展": 1, "家居软装展": 2, "邻近行业机会": 3}
|
||||
for item in deduped:
|
||||
focus = set(item.get("event_focus") or [])
|
||||
if focus & {"床品与套件", "窗帘与遮阳", "酒店与工程"}:
|
||||
item["industry_relevance"] = "核心家纺展"
|
||||
elif focus & {"纺织面料", "功能性材料", "可持续材料"}:
|
||||
item["industry_relevance"] = "纺织面料展"
|
||||
elif focus & {"空间与装饰面料", "地毯与地材", "家具与整体软装"}:
|
||||
item["industry_relevance"] = "家居软装展"
|
||||
else:
|
||||
item["industry_relevance"] = "邻近行业机会"
|
||||
return sorted(deduped, key=lambda row: (relevance_order[row["industry_relevance"]], row["start_date"], row["title"]))
|
||||
|
||||
|
||||
def _refine_translation(value: str) -> str:
|
||||
replacements = {
|
||||
"家纺采购": "家用纺织品采购",
|
||||
"服装采购": "服装与面料采购",
|
||||
"家具陈设": "家具与软装",
|
||||
"法兰克福/主要": "法兰克福",
|
||||
"纺织技术": "纺织技术与材料",
|
||||
}
|
||||
output = value
|
||||
for source, target in replacements.items():
|
||||
output = output.replace(source, target)
|
||||
return output
|
||||
|
||||
|
||||
TITLE_OVERRIDES = {
|
||||
"Zhejiang Export Fair (Vietnam)": "浙江出口商品(越南)交易会",
|
||||
"Creativ": "Creativ 萨尔茨堡创意手作展",
|
||||
"Habitare": "芬兰 Habitare 家居设计展",
|
||||
"Ambienta": "克罗地亚 Ambienta 家居与室内设计展",
|
||||
"FIM": "瓦伦西亚 FIM 家具与室内设计展",
|
||||
"TEXTILHOGAR": "西班牙 TEXTILHOGAR 家用纺织品展",
|
||||
"SICAM": "意大利 SICAM 家具部件与室内材料展",
|
||||
"MASTERSHOES": "土耳其 MASTERSHOES 鞋材与时尚展",
|
||||
"Ispo": "ISPO 运动用品与功能面料展",
|
||||
"Furniture Design Now": "贝尔格莱德 Furniture Design Now 家具设计展",
|
||||
"HOMETEX": "伊斯坦布尔 HOMETEX 家用纺织品展",
|
||||
"Texcare International": "Texcare International 法兰克福纺织护理展",
|
||||
"Texhibition": "伊斯坦布尔 Texhibition 面料与纺织辅料展",
|
||||
"ZUCHEX": "伊斯坦布尔 ZUCHEX 家庭用品展",
|
||||
"Egypt Stitch and Tex": "埃及 Stitch & Tex 纺织服装展",
|
||||
"Feria Habitat Valencia": "瓦伦西亚 Habitat 家具与室内设计展",
|
||||
"ANAMOB": "土耳其 ANAMOB 家具展",
|
||||
"China International Occupational Safety & Health Goods Expo (CIOSH)": "中国国际劳动保护用品交易会 CIOSH",
|
||||
"Cinte Techtextil China": "中国国际产业用纺织品及非织造布展 Cinte Techtextil China",
|
||||
"Neonyt Paris (Sommer) licensed to WSN Développement SAS": "巴黎 Neonyt 可持续时尚展(夏季)",
|
||||
"Val:ue Milan - Spring": "米兰 Val:ue 面料采购展(春季)",
|
||||
"Texprocess Americas": "Texprocess Americas 美洲纺织加工技术展",
|
||||
"Apparel Sourcing Tashkent licensed to LLC BMC-LLP": "塔什干服装与面料采购展 Apparel Sourcing",
|
||||
"Heimtextil Uzbekistan licensed to LLC BMC-LLP": "乌兹别克斯坦 Heimtextil 家用纺织品展",
|
||||
"Texworld Tashkent licesed to LLC BMC-LLP": "塔什干 Texworld 国际纺织面料展",
|
||||
"Warsaw Home & Contracts": "华沙 Home & Contract 家居与软装展",
|
||||
"Design Shanghai": "上海国际设计展 Design Shanghai",
|
||||
"Techtextil North America": "Techtextil North America 北美产业用纺织品展",
|
||||
"Techtextil India": "Techtextil India 印度产业用纺织品展",
|
||||
"Textile Asia": "巴基斯坦 Textile Asia 纺织展",
|
||||
"Heimtextil": "Heimtextil 法兰克福家用及商用纺织品展",
|
||||
"Techtextil": "Techtextil 法兰克福产业用纺织品展",
|
||||
"Texprocess": "Texprocess 法兰克福纺织加工技术展",
|
||||
"Home Textiles Sourcing": "纽约 Home Textiles Sourcing 家用纺织品采购展",
|
||||
}
|
||||
|
||||
|
||||
def translate_exhibitions(items: list[dict[str, Any]], output_dir: Path, max_new: int = 200) -> None:
|
||||
cache = load_zh_cache(output_dir)
|
||||
session = make_session()
|
||||
translated_count = 0
|
||||
cache_changed = False
|
||||
for item in items:
|
||||
if item.get("source_id") not in ("expo_texpertise_network", "expo_ufi_approved"):
|
||||
continue
|
||||
translated_any = False
|
||||
for original_key, target_key in (("title_original", "title"), ("summary_original", "summary")):
|
||||
original = normalize_text(str(item.get(original_key) or ""))
|
||||
if not original:
|
||||
continue
|
||||
if original_key == "title_original" and original in TITLE_OVERRIDES:
|
||||
item[target_key] = TITLE_OVERRIDES[original]
|
||||
translated_any = True
|
||||
continue
|
||||
translated = cache.get(original)
|
||||
if not translated and translated_count < max_new:
|
||||
translated = translate_to_zh_cn(session, original)
|
||||
if translated:
|
||||
cache[original] = translated
|
||||
translated_count += 1
|
||||
cache_changed = True
|
||||
if translated:
|
||||
item[target_key] = _refine_translation(translated)
|
||||
translated_any = True
|
||||
item["translation_status"] = "已翻译并保留官方原文" if translated_any else "保留官方原文"
|
||||
if cache_changed:
|
||||
save_zh_cache(output_dir, cache)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""权威源 HTTP 获取、证据词校验与稳定内容哈希。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if __package__:
|
||||
from .intelligence_sources import SOURCES, SourceSpec
|
||||
else:
|
||||
from intelligence_sources import SOURCES, SourceSpec
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
class LegacyCertificateAdapter(HTTPAdapter):
|
||||
"""仍校验 CA 与域名,仅兼容少数政府站点缺少 SKI 的旧证书。"""
|
||||
|
||||
@staticmethod
|
||||
def _context():
|
||||
context = ssl.create_default_context()
|
||||
if hasattr(ssl, "VERIFY_X509_STRICT"):
|
||||
context.verify_flags &= ~ssl.VERIFY_X509_STRICT
|
||||
return context
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._context()
|
||||
return super().init_poolmanager(*args, **kwargs)
|
||||
|
||||
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
||||
proxy_kwargs["proxy_ssl_context"] = self._context()
|
||||
proxy_kwargs["ssl_context"] = self._context()
|
||||
return super().proxy_manager_for(proxy, **proxy_kwargs)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
source: SourceSpec
|
||||
ok: bool
|
||||
status_code: int | None
|
||||
final_url: str
|
||||
content_type: str
|
||||
elapsed_ms: int
|
||||
size_bytes: int
|
||||
content_hash: str
|
||||
checked_at: str
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
image: str = ""
|
||||
published_at: str = ""
|
||||
page_language: str = ""
|
||||
evidence: list[str] | None = None
|
||||
error: str = ""
|
||||
raw: bytes = b""
|
||||
changed: bool = False
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def make_session() -> requests.Session:
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=3,
|
||||
connect=3,
|
||||
read=3,
|
||||
backoff_factor=1.2,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset(("GET",)),
|
||||
)
|
||||
session.mount("http://", HTTPAdapter(max_retries=retry))
|
||||
session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
session.mount("https://www.bsmi.gov.tw", LegacyCertificateAdapter(max_retries=retry))
|
||||
session.headers.update({
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
||||
})
|
||||
return session
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return " ".join((value or "").replace("\u00a0", " ").split())
|
||||
|
||||
|
||||
def fetch_json(url: str):
|
||||
response = make_session().get(url, timeout=(10, 35))
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _meta_content(soup: BeautifulSoup, *selectors: str) -> str:
|
||||
for selector in selectors:
|
||||
node = soup.select_one(selector)
|
||||
if node and node.get("content"):
|
||||
return normalize_text(str(node.get("content")))
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_html_metadata(raw: bytes, base_url: str) -> tuple[str, str, str, str, str, str]:
|
||||
soup = BeautifulSoup(raw, "html.parser")
|
||||
page_language = normalize_text(str(soup.html.get("lang") or "")) if soup.html else ""
|
||||
title = _meta_content(soup, 'meta[property="og:title"]', 'meta[name="twitter:title"]')
|
||||
if not title and soup.title:
|
||||
title = normalize_text(soup.title.get_text(" ", strip=True))
|
||||
description = _meta_content(
|
||||
soup,
|
||||
'meta[property="og:description"]',
|
||||
'meta[name="description"]',
|
||||
'meta[name="twitter:description"]',
|
||||
)
|
||||
image = _meta_content(soup, 'meta[property="og:image"]', 'meta[name="twitter:image"]')
|
||||
if image:
|
||||
image = urljoin(base_url, image)
|
||||
published_at = _meta_content(
|
||||
soup,
|
||||
'meta[property="article:published_time"]',
|
||||
'meta[name="date"]',
|
||||
'meta[name="DC.date"]',
|
||||
)
|
||||
for node in soup(("script", "style", "noscript", "svg")):
|
||||
node.decompose()
|
||||
text = normalize_text(soup.get_text(" ", strip=True))
|
||||
return title[:240], description[:600], image[:1000], published_at[:80], text, page_language[:40]
|
||||
|
||||
|
||||
def _stable_content_hash(raw: bytes, content_type: str, semantic_text: str = "") -> str:
|
||||
if "json" in content_type:
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
pass
|
||||
if semantic_text:
|
||||
stable_text = normalize_text(semantic_text)
|
||||
stable_text = re.sub(r"\b\d{1,2}:\d{2}(?::\d{2})?\b", "", stable_text)
|
||||
stable_text = re.sub(r"\b\d{8,}\b", "", stable_text)
|
||||
return hashlib.sha256(stable_text[:2_000_000].encode("utf-8")).hexdigest()
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def fetch_source(source: SourceSpec) -> FetchResult:
|
||||
started = time.perf_counter()
|
||||
checked_at = iso_now()
|
||||
try:
|
||||
response = make_session().get(source.url, timeout=(12, 45), allow_redirects=True)
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000)
|
||||
raw = response.content
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
title = description = image = published_at = page_language = ""
|
||||
evidence_text = ""
|
||||
if "html" in content_type or raw.lstrip().startswith(b"<"):
|
||||
title, description, image, published_at, evidence_text, page_language = _parse_html_metadata(raw, response.url)
|
||||
evidence_text = f"{title} {description} {evidence_text}"
|
||||
elif "json" in content_type:
|
||||
payload = response.json()
|
||||
if isinstance(payload, str) and "<html" in payload[:2_000].lower():
|
||||
title, description, image, published_at, evidence_text, page_language = _parse_html_metadata(payload.encode("utf-8"), response.url)
|
||||
evidence_text = f"{title} {description} {evidence_text}"
|
||||
else:
|
||||
evidence_text = raw[:2_000_000].decode("utf-8", errors="ignore")
|
||||
elif source.extractor == "pdf":
|
||||
evidence_text = response.url
|
||||
else:
|
||||
evidence_text = raw[:2_000_000].decode("utf-8", errors="ignore")
|
||||
|
||||
matched = [token for token in source.expected if token.lower() in evidence_text.lower()]
|
||||
ok = response.ok and len(raw) >= 200 and len(matched) == len(source.expected)
|
||||
if not response.ok:
|
||||
error = f"HTTP {response.status_code}"
|
||||
elif len(raw) < 200:
|
||||
error = "响应正文过短"
|
||||
elif len(matched) != len(source.expected):
|
||||
missing = [token for token in source.expected if token.lower() not in evidence_text.lower()]
|
||||
error = f"缺少证据词:{', '.join(missing)}"
|
||||
else:
|
||||
error = ""
|
||||
|
||||
hash_text = f"{title} {description}" if source.change_detection == "metadata" else evidence_text
|
||||
return FetchResult(
|
||||
source=source,
|
||||
ok=ok,
|
||||
status_code=response.status_code,
|
||||
final_url=response.url,
|
||||
content_type=content_type.split(";")[0],
|
||||
elapsed_ms=elapsed_ms,
|
||||
size_bytes=len(raw),
|
||||
content_hash=_stable_content_hash(raw, content_type, hash_text),
|
||||
checked_at=checked_at,
|
||||
title=title,
|
||||
description=description,
|
||||
image=image,
|
||||
published_at=published_at,
|
||||
page_language=page_language,
|
||||
evidence=matched,
|
||||
error=error,
|
||||
raw=raw,
|
||||
)
|
||||
except Exception as exc:
|
||||
return FetchResult(
|
||||
source=source,
|
||||
ok=False,
|
||||
status_code=None,
|
||||
final_url=source.url,
|
||||
content_type="",
|
||||
elapsed_ms=round((time.perf_counter() - started) * 1000),
|
||||
size_bytes=0,
|
||||
content_hash="",
|
||||
checked_at=checked_at,
|
||||
evidence=[],
|
||||
error=str(exc)[:300],
|
||||
)
|
||||
|
||||
|
||||
def fetch_all_sources(max_workers: int) -> dict[str, FetchResult]:
|
||||
results: dict[str, FetchResult] = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(fetch_source, source): source for source in SOURCES}
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
results[result.source.id] = result
|
||||
marker = "通过" if result.ok else "失败"
|
||||
print(f"[{marker}] {result.source.name}: {result.status_code or '-'} / {result.elapsed_ms}ms {result.error}")
|
||||
return results
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""家纺跨境经营者常用平台目录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
from .intelligence_web_translation import translation_fields
|
||||
except ImportError:
|
||||
from intelligence_web_translation import translation_fields
|
||||
|
||||
|
||||
PLATFORMS: tuple[dict[str, Any], ...] = (
|
||||
{"id": "amazon-us", "name": "Amazon 美国", "logo": "amazon", "markets": ["美国"], "region": "北美", "type": "综合电商", "access_level": "A", "access_label": "可直接申请", "cross_border": "全球开店面向中国卖家,FBA 体系成熟", "textile_category": "Home & Kitchen > Bedding;适合床品、毯类、枕芯和浴室纺织品", "fit_sellers": ["电商卖家", "品牌商", "成品供货商"], "official_url": "https://www.amazon.com/s?k=bedding", "seller_url": "https://gs.amazon.cn/", "confidence": "已核实"},
|
||||
{"id": "amazon-uk", "name": "Amazon 英国", "logo": "amazon", "markets": ["英国"], "region": "欧洲", "type": "综合电商", "access_level": "A", "access_label": "全球开店开放", "cross_border": "中国卖家可通过 Amazon Global Selling 进入英国站,需单独核对英国 VAT、产品标签和退货地址", "textile_category": "Home & Kitchen > Bedding;适合英规床品、保暖毯、窗帘和浴室纺织品", "fit_sellers": ["电商卖家", "品牌商", "成品供货商"], "official_url": "https://www.amazon.co.uk/s?k=bedding", "seller_url": "https://sell.amazon.co.uk/global-selling/china", "confidence": "已核实"},
|
||||
{"id": "amazon-de", "name": "Amazon 德国", "logo": "amazon", "markets": ["德国", "欧盟"], "region": "欧洲", "type": "综合电商", "access_level": "A", "access_label": "全球开店开放", "cross_border": "中国卖家可申请德国站,需完成欧盟 VAT、EPR、GPSR 和德语商品信息等要求", "textile_category": "Küche, Haushalt & Wohnen > Bettwäsche;适合德规尺寸床品、窗帘和认证明确的功能纺织品", "fit_sellers": ["电商卖家", "品牌商", "成品供货商"], "official_url": "https://www.amazon.de/s?k=bettwaesche", "seller_url": "https://sell.amazon.de/en/", "confidence": "已核实"},
|
||||
{"id": "amazon-fr", "name": "Amazon 法国", "logo": "amazon", "markets": ["法国", "欧盟"], "region": "欧洲", "type": "综合电商", "access_level": "A", "access_label": "全球开店开放", "cross_border": "中国卖家可申请法国站,需处理法国 VAT、EPR、法语标签和客服", "textile_category": "Cuisine et Maison > Linge de lit;适合法式床品、桌布、浴巾和软装套系", "fit_sellers": ["电商卖家", "品牌商", "成品供货商"], "official_url": "https://www.amazon.fr/s?k=linge+de+lit", "seller_url": "https://sell.amazon.fr/", "confidence": "已核实"},
|
||||
{"id": "amazon-jp", "name": "Amazon 日本", "logo": "amazon", "markets": ["日本"], "region": "东亚", "type": "综合电商", "access_level": "A", "access_label": "全球开店开放", "cross_border": "海外卖家可申请日本站,需按日本尺寸、日语标签、进口和产品法规经营", "textile_category": "ホーム&キッチン > 寝具;适合日规单人床品、毛巾、凉感寝具和收纳纺织品", "fit_sellers": ["电商卖家", "品牌商", "成品供货商"], "official_url": "https://www.amazon.co.jp/s?k=%E5%AF%9D%E5%85%B7", "seller_url": "https://sell.amazon.co.jp/", "confidence": "已核实"},
|
||||
{"id": "amazon-in", "name": "Amazon 印度", "logo": "amazon", "markets": ["印度"], "region": "南亚", "type": "综合电商", "access_level": "B", "access_label": "跨境方案需审核", "cross_border": "官方提供从海外使用 FBA 进入印度的方案,但 GST、进口、税务和合规门槛较高,申请前需人工确认主体路径", "textile_category": "Home & Kitchen > Bedsheets;适合棉质床单、轻薄毯、毛巾和高温气候家纺", "fit_sellers": ["品牌商", "成品供货商", "有印度合规能力的卖家"], "official_url": "https://www.amazon.in/b?ie=UTF8&node=10213829031", "seller_url": "https://sell.amazon.in/landing/sell-to-india-from-anywhere/sell-with-fba", "confidence": "已核实"},
|
||||
{"id": "walmart", "name": "Walmart Marketplace", "logo": "walmart", "markets": ["美国"], "region": "北美", "type": "零售平台", "access_level": "B", "access_label": "审核制", "cross_border": "中国卖家可申请,需通过资质与履约审核", "textile_category": "Home > Bedding;适合高性价比套装和稳定补货商品", "fit_sellers": ["品牌商", "成品供货商"], "official_url": "https://www.walmart.com/search?q=bedding", "seller_url": "https://marketplace.walmart.com/", "confidence": "已核实"},
|
||||
{"id": "ebay", "name": "eBay", "logo": "ebay", "markets": ["美国", "英国", "德国", "澳大利亚"], "region": "全球", "type": "综合电商", "access_level": "A", "access_label": "可直接申请", "cross_border": "中国卖家生态成熟,需按站点设置物流、税费与退货", "textile_category": "Home & Garden > Bedding;适合标准品、尾货和差异化款式", "fit_sellers": ["电商卖家", "成品供货商", "贸易商"], "official_url": "https://www.ebay.com/sch/i.html?_nkw=bedding", "seller_url": "https://www.ebay.com/sl/sell", "confidence": "已核实"},
|
||||
{"id": "etsy", "name": "Etsy", "logo": "etsy", "markets": ["美国", "英国", "德国", "法国", "澳大利亚", "加拿大"], "region": "欧美", "type": "手作定制", "access_level": "A", "access_label": "可直接申请", "cross_border": "适合原创、个性化和小批量家纺,需重视设计版权", "textile_category": "Home & Living > Bedding;适合定制床品、刺绣、抱枕和桌布", "fit_sellers": ["设计工作室", "电商卖家", "品牌商"], "official_url": "https://www.etsy.com/c/home-and-living/bedding", "seller_url": "https://www.etsy.com/sell", "confidence": "已核实"},
|
||||
{"id": "temu", "name": "Temu", "logo": "temu", "markets": ["美国", "英国", "德国", "法国", "日本", "韩国", "澳大利亚", "巴西", "中东"], "region": "全球", "type": "折扣电商", "access_level": "A", "access_label": "可直接申请", "cross_border": "全托管与半托管模式面向中国工厂和卖家,具体站点规则以后台为准", "textile_category": "Home & Kitchen;适合价格敏感、轻量化、组合装和快速迭代商品", "fit_sellers": ["工厂型卖家", "成品供货商", "电商卖家"], "official_url": "https://www.temu.com/search_result.html?search_key=bedding", "seller_url": "https://seller.temu.com/", "confidence": "已核实"},
|
||||
{"id": "tiktok-shop", "name": "TikTok Shop", "logo": "tiktok", "markets": ["美国", "英国", "东南亚"], "region": "全球", "type": "社交电商", "access_level": "A", "access_label": "按站点开放", "cross_border": "不同国家开放模式持续变化,申请前必须核对卖家中心当前站点", "textile_category": "Home & Living;适合可演示材质、场景改造、宿舍套装和达人测评商品", "fit_sellers": ["电商卖家", "品牌商", "工厂型卖家"], "official_url": "https://www.tiktok.com/search?q=bedding", "seller_url": "https://seller.tiktokglobalshop.com/", "confidence": "已核实"},
|
||||
{"id": "shopee", "name": "Shopee", "logo": "shopee", "markets": ["新加坡", "马来西亚", "泰国", "印度尼西亚", "越南", "菲律宾", "巴西"], "region": "东南亚及拉美", "type": "综合电商", "access_level": "A", "access_label": "可直接申请", "cross_border": "跨境开店和 SLS 物流体系成熟,需按站点活动与语言运营", "textile_category": "Home & Living > Bedding;适合轻薄、快干、小规格和组合装", "fit_sellers": ["电商卖家", "成品供货商", "工厂型卖家"], "official_url": "https://shopee.sg/search?keyword=bedding", "seller_url": "https://shopee.cn/", "confidence": "已核实"},
|
||||
{"id": "lazada", "name": "Lazada", "logo": "lazada", "markets": ["新加坡", "马来西亚", "泰国", "印度尼西亚", "越南", "菲律宾"], "region": "东南亚", "type": "综合电商", "access_level": "A", "access_label": "需核对当前招商", "cross_border": "跨境招商与站点条件会变化,申请前核对卖家中心最新要求", "textile_category": "Home & Living;适合热带气候床品、凉感面料和浴室纺织品", "fit_sellers": ["电商卖家", "成品供货商"], "official_url": "https://www.lazada.sg/catalog/?q=bedding", "seller_url": "https://sellercenter.lazada.com/", "confidence": "需核对条件"},
|
||||
{"id": "mercado-libre", "name": "Mercado Libre", "logo": "mercado-libre", "markets": ["巴西", "墨西哥", "智利", "哥伦比亚"], "region": "拉丁美洲", "type": "综合电商", "access_level": "A", "access_label": "跨境项目开放", "cross_border": "通过 Global Selling 覆盖重点拉美市场,税费和配送承诺需本地化;官网支持切换简体中文", "textile_category": "Hogar > Ropa de cama;适合床品套装、毛巾和季节性软装", "fit_sellers": ["电商卖家", "成品供货商", "品牌商"], "official_url": "https://listado.mercadolibre.com.mx/ropa-de-cama", "seller_url": "https://global-selling.mercadolibre.com/", "language_url": "https://global-selling.mercadolibre.com/learning-center", "confidence": "已核实"},
|
||||
{"id": "ozon", "name": "Ozon", "logo": "ozon", "markets": ["俄罗斯"], "region": "俄罗斯及欧亚", "type": "综合电商", "access_level": "A", "access_label": "可直接申请", "cross_border": "中国卖家可使用跨境卖家中心,需处理俄语内容、认证和物流", "textile_category": "Дом > Текстиль;适合保暖床品、毯类、窗帘和浴室纺织品", "fit_sellers": ["电商卖家", "成品供货商"], "official_url": "https://www.ozon.ru/search/?text=постельное%20белье", "seller_url": "https://seller.ozon.ru/", "confidence": "已核实"},
|
||||
{"id": "wildberries", "name": "Wildberries", "logo": "wildberries", "markets": ["俄罗斯"], "region": "俄罗斯及欧亚", "type": "综合电商", "access_level": "A", "access_label": "可直接申请", "cross_border": "已面向中国卖家开放,入驻和跨境物流条件以卖家后台为准", "textile_category": "Дом > Текстиль;适合大众价位床品、毯类和家居软装", "fit_sellers": ["电商卖家", "工厂型卖家"], "official_url": "https://www.wildberries.ru/catalog/dom-i-dacha/tekstil", "seller_url": "https://seller.wildberries.ru/", "confidence": "已核实"},
|
||||
{"id": "rakuten", "name": "乐天市场 Rakuten", "logo": "rakuten", "markets": ["日本"], "region": "东亚", "type": "综合电商", "access_level": "B", "access_label": "需日本主体或代理", "cross_border": "以日本本地主体、代理或平台招商为主,不应按普通跨境店理解", "textile_category": "インテリア・寝具;适合日系单人床品、收纳、毛巾和生活杂货", "fit_sellers": ["品牌商", "成品供货商"], "official_url": "https://search.rakuten.co.jp/search/mall/寝具/", "seller_url": "https://www.rakuten.co.jp/ec/open/", "confidence": "需核对条件"},
|
||||
{"id": "coupang", "name": "Coupang", "logo": "coupang", "markets": ["韩国"], "region": "东亚", "type": "综合电商", "access_level": "A", "access_label": "跨境项目开放", "cross_border": "中国跨境卖家可申请,需韩语商品信息和平台物流配合", "textile_category": "홈인테리어 > 침구;适合韩式床品、毯类、收纳和季节商品", "fit_sellers": ["电商卖家", "成品供货商"], "official_url": "https://www.coupang.com/np/search?q=침구", "seller_url": "https://marketplace.coupang.com/", "confidence": "已核实"},
|
||||
{"id": "allegro", "name": "Allegro", "logo": "allegro", "markets": ["波兰"], "region": "中东欧", "type": "综合电商", "access_level": "B", "access_label": "国际卖家可申请", "cross_border": "面向国际卖家招商,申请和结算条件需以卖家中心当前规则为准", "textile_category": "Dom > Pościel;适合床品、毯类和大众家居软装", "fit_sellers": ["电商卖家", "成品供货商"], "official_url": "https://allegro.pl/listing?string=pościel", "seller_url": "https://allegro.pl/dla-sprzedajacych", "confidence": "需核对条件"},
|
||||
{"id": "otto", "name": "OTTO", "logo": "otto", "markets": ["德国"], "region": "中西欧", "type": "综合电商", "access_level": "B", "access_label": "需欧盟主体", "cross_border": "通常需要欧盟主体、德国税务与德语客服,适合已有欧洲能力的品牌", "textile_category": "Wohnen > Heimtextilien;适合中高品质床品、窗帘和浴室纺织品", "fit_sellers": ["品牌商", "成品供货商"], "official_url": "https://www.otto.de/heimtextilien/", "seller_url": "https://www.otto.market/", "confidence": "已核实"},
|
||||
{"id": "cdiscount", "name": "Cdiscount", "logo": "cdiscount", "markets": ["法国"], "region": "中西欧", "type": "综合电商", "access_level": "B", "access_label": "国际卖家可申请", "cross_border": "Marketplace 面向国际卖家,法国税务、EPR 与法语客服需单独核对", "textile_category": "Maison > Linge de maison;适合床品、毛巾、桌布和家居软装", "fit_sellers": ["电商卖家", "成品供货商"], "official_url": "https://www.cdiscount.com/maison/linge-maison/v-11710-11710.html", "seller_url": "https://seller.cdiscount.com/", "confidence": "需核对条件"},
|
||||
{"id": "wayfair", "name": "Wayfair", "logo": "wayfair", "markets": ["美国", "加拿大", "英国", "德国"], "region": "欧美", "type": "垂直家居", "access_level": "B", "access_label": "供应商审核制", "cross_border": "以供应商合作为主,适合具备稳定供货、海外仓或本地主体能力的企业", "textile_category": "Bedding 一级类目;适合床品、毯类、枕芯和窗帘等完整家居系列", "fit_sellers": ["成品供货商", "品牌商", "工厂型卖家"], "official_url": "https://www.wayfair.com/bed-bath/sb0/bedding-c481592.html", "seller_url": "https://partners.wayfair.com/", "confidence": "已核实"},
|
||||
{"id": "noon", "name": "Noon", "logo": "noon", "markets": ["阿联酋", "沙特阿拉伯", "埃及"], "region": "中东", "type": "综合电商", "access_level": "B", "access_label": "需本地主体或代理", "cross_border": "通常需要当地经营与税务能力,适合通过代理或区域合作进入", "textile_category": "Home & Kitchen;适合清凉床品、浴室纺织品和礼赠软装", "fit_sellers": ["品牌商", "成品供货商"], "official_url": "https://www.noon.com/uae-en/search?q=bedding", "seller_url": "https://sell.noon.com/", "confidence": "需核对条件"},
|
||||
{"id": "bol", "name": "bol.com", "logo": "bol", "markets": ["荷兰", "比利时"], "region": "欧洲", "type": "综合电商", "access_level": "B", "access_label": "欧洲主体审核制", "cross_border": "官方 Partner Platform 面向符合条件的荷兰、比利时及欧洲经营主体,中国大陆主体不应按直接开放理解", "textile_category": "Wonen > Beddengoed;适合欧规床品、被套、毛巾和家庭软装", "fit_sellers": ["欧洲品牌商", "欧洲成品供货商"], "official_url": "https://www.bol.com/nl/nl/s/?searchtext=beddengoed", "seller_url": "https://partnerplatform.bol.com/en/cdp/selling-via-bol", "confidence": "已核实"},
|
||||
{"id": "kaufland", "name": "Kaufland Global Marketplace", "logo": "kaufland", "markets": ["德国", "波兰", "捷克", "斯洛伐克", "法国", "意大利", "奥地利"], "region": "欧洲", "type": "综合电商", "access_level": "B", "access_label": "多国平台招商审核", "cross_border": "官方多国 Marketplace 注册开放,但中国主体直入、VAT、退货地址和本地语言服务需人工核验", "textile_category": "Wohnen > Bettwäsche;适合欧规床品、毯类、窗帘和大众家居纺织品", "fit_sellers": ["欧洲品牌商", "成品供货商", "具备欧盟履约能力的卖家"], "official_url": "https://www.kaufland.de/c/bettwaesche/~4821/", "seller_url": "https://www.kauflandglobalmarketplace.com/en/seller-registration/", "confidence": "已核实"},
|
||||
{"id": "shopify", "name": "Shopify 独立站", "logo": "shopify", "markets": ["全球"], "region": "全球", "type": "独立站", "access_level": "A", "access_label": "可自主建站", "cross_border": "不是 Marketplace;中国企业可搭建品牌独立站,但支付、获客、税务、隐私和跨境履约需自行解决", "textile_category": "自建家纺品牌站;适合有内容、设计、复购和私域经营能力的床品与软装品牌", "fit_sellers": ["品牌商", "设计工作室", "具备独立站运营能力的卖家"], "official_url": "https://www.shopify.com/zh", "seller_url": "https://www.shopify.com/zh", "confidence": "已核实"},
|
||||
{"id": "alibaba", "name": "Alibaba.com", "logo": "alibaba", "markets": ["全球"], "region": "全球", "type": "B2B批发", "access_level": "A", "access_label": "中国供应商可申请", "cross_border": "中国供应商可通过官方 Seller Center 开展出口 B2B,会员、认证和询盘运营条件以平台当前方案为准", "textile_category": "Home Textile;适合床品、毛巾、毯类、窗帘、面料和酒店布草批量出口", "fit_sellers": ["工厂型卖家", "贸易商", "成品供货商"], "official_url": "https://www.alibaba.com/trade/search?SearchText=bedding", "seller_url": "https://seller.alibaba.com/", "confidence": "已核实"},
|
||||
{"id": "shein-marketplace", "name": "SHEIN Marketplace", "logo": "shein", "markets": ["美国", "欧洲", "中东", "巴西"], "region": "全球", "type": "时尚电商", "access_level": "B", "access_label": "平台招商审核", "cross_border": "中国有官方 Marketplace 招商入口,但不同站点主体、地址和税号要求不同;美国公开入口要求美国经营主体,不能视为全球无条件开放", "textile_category": "Home & Living;适合趋势型床品、抱枕、毯类、窗帘和快反软装", "fit_sellers": ["工厂型卖家", "品牌商", "快反供应商"], "official_url": "https://www.shein.com/pdsearch/bedding/", "seller_url": "https://sheinmarketplace.cn/", "confidence": "已核实"},
|
||||
)
|
||||
|
||||
|
||||
PLATFORM_HOME_URLS = {
|
||||
"amazon-us": "https://www.amazon.com/", "walmart": "https://www.walmart.com/", "ebay": "https://www.ebay.com/",
|
||||
"etsy": "https://www.etsy.com/", "temu": "https://www.temu.com/", "tiktok-shop": "https://www.tiktok.com/",
|
||||
"shopee": "https://shopee.sg/", "lazada": "https://www.lazada.com/", "mercado-libre": "https://www.mercadolibre.com/",
|
||||
"ozon": "https://www.ozon.ru/", "wildberries": "https://www.wildberries.ru/", "rakuten": "https://www.rakuten.co.jp/",
|
||||
"coupang": "https://www.coupang.com/", "allegro": "https://allegro.pl/", "otto": "https://www.otto.de/",
|
||||
"cdiscount": "https://www.cdiscount.com/", "wayfair": "https://www.wayfair.com/", "noon": "https://www.noon.com/",
|
||||
"amazon-uk": "https://www.amazon.co.uk/", "amazon-de": "https://www.amazon.de/", "amazon-fr": "https://www.amazon.fr/",
|
||||
"amazon-jp": "https://www.amazon.co.jp/", "amazon-in": "https://www.amazon.in/", "bol": "https://www.bol.com/",
|
||||
"kaufland": "https://www.kaufland.de/", "shopify": "https://www.shopify.com/zh", "alibaba": "https://www.alibaba.com/",
|
||||
"shein-marketplace": "https://www.shein.com/",
|
||||
}
|
||||
|
||||
# 运营研究标记:依据家纺类目成熟度、平台覆盖与中国卖家可操作性整理,非实时搜索量。
|
||||
PLATFORM_RANKING: dict[str, tuple[int, int]] = {
|
||||
"amazon-us": (98, 55), "amazon-uk": (90, 48), "amazon-de": (91, 45), "amazon-fr": (86, 44),
|
||||
"amazon-jp": (87, 46), "amazon-in": (72, 68), "walmart": (88, 42), "ebay": (90, 40),
|
||||
"etsy": (76, 57), "temu": (84, 92), "tiktok-shop": (79, 95), "shopee": (83, 88),
|
||||
"lazada": (78, 78), "mercado-libre": (80, 82), "ozon": (81, 80), "wildberries": (79, 74),
|
||||
"rakuten": (77, 42), "coupang": (80, 65), "allegro": (75, 62), "otto": (73, 30),
|
||||
"cdiscount": (70, 39), "wayfair": (86, 35), "noon": (68, 85), "bol": (67, 34),
|
||||
"kaufland": (71, 50), "shopify": (82, 76), "alibaba": (95, 52), "shein-marketplace": (78, 88),
|
||||
}
|
||||
|
||||
|
||||
def build_platforms(checked_at: str, session: requests.Session) -> list[dict[str, Any]]:
|
||||
def status(item: dict[str, Any]) -> int:
|
||||
try:
|
||||
response = requests.get(
|
||||
str(item["seller_url"]),
|
||||
headers=dict(session.headers),
|
||||
timeout=15,
|
||||
allow_redirects=True,
|
||||
stream=True,
|
||||
)
|
||||
code = response.status_code
|
||||
response.close()
|
||||
return code
|
||||
except requests.RequestException:
|
||||
return 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
status_codes = dict(zip((item["id"] for item in PLATFORMS), executor.map(status, PLATFORMS)))
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in PLATFORMS:
|
||||
homepage_url = PLATFORM_HOME_URLS[item["id"]]
|
||||
sales_score, emergence_score = PLATFORM_RANKING.get(item["id"], (50, 50))
|
||||
translated = {
|
||||
prefix: translation_fields(url)
|
||||
for prefix, url in (("official", item["official_url"]), ("homepage", homepage_url), ("seller", item["seller_url"]))
|
||||
}
|
||||
items.append({
|
||||
**item,
|
||||
"title": item["name"],
|
||||
"section": "platform",
|
||||
"homepage_url": homepage_url,
|
||||
"url": item["seller_url"],
|
||||
"source_url": item["seller_url"],
|
||||
"source": "平台官方卖家中心",
|
||||
"authority": "平台官方",
|
||||
"summary": f"{item['cross_border']}。{item['textile_category']}",
|
||||
"home_textile_sales_score": sales_score,
|
||||
"emergence_score": emergence_score,
|
||||
"verified_at": checked_at,
|
||||
"checked_at": checked_at,
|
||||
**{
|
||||
f"{prefix}_{field}": values[field]
|
||||
for prefix, values in translated.items()
|
||||
for field in ("needs_translation", "translation_url", "translation_fallback_url")
|
||||
},
|
||||
"seller_url_status": status_codes[item["id"]],
|
||||
"link_status": (
|
||||
"可访问" if 200 <= status_codes[item["id"]] < 400
|
||||
else "官网限制自动检查" if status_codes[item["id"]] in (401, 403)
|
||||
else "待人工复核"
|
||||
),
|
||||
"poster": {"eyebrow": "平台渠道", "accent": "#1e6f5c", "ink": "#10251f", "paper": "#eef8f4"},
|
||||
})
|
||||
return items
|
||||
@@ -0,0 +1,827 @@
|
||||
"""家纺情报局权威源注册表。
|
||||
|
||||
每个源都必须能够被本地抓取器直接访问,并通过正文证据词校验。
|
||||
展会日期来自对应官网当前公开页面,固定字段用于统一不同站点的页面结构。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceSpec:
|
||||
id: str
|
||||
name: str
|
||||
section: str
|
||||
region: str
|
||||
url: str
|
||||
expected: tuple[str, ...]
|
||||
authority: str = "官方"
|
||||
extractor: str = "html"
|
||||
title_zh: str = ""
|
||||
summary_zh: str = ""
|
||||
start_date: str = ""
|
||||
end_date: str = ""
|
||||
venue: str = ""
|
||||
opportunity: str = ""
|
||||
risk_level: str = "关注"
|
||||
policy_type: str = ""
|
||||
platforms: tuple[str, ...] = ()
|
||||
markets: tuple[str, ...] = ()
|
||||
action_zh: str = ""
|
||||
change_detection: str = "semantic"
|
||||
|
||||
|
||||
SOURCES: tuple[SourceSpec, ...] = (
|
||||
# 备货日历:政府节假日、宗教历法、消费季和大型体育节点。
|
||||
SourceSpec(
|
||||
"calendar_nager",
|
||||
"Nager.Date 全球公共假日",
|
||||
"calendar",
|
||||
"全球",
|
||||
"https://date.nager.at/api/v3/AvailableCountries",
|
||||
("countryCode", "China", "United States"),
|
||||
authority="结构化公共假日数据",
|
||||
extractor="nager",
|
||||
title_zh="全球重点市场公共假日",
|
||||
summary_zh="按中国、美国、英国、德国、法国、日本、加拿大、澳大利亚等重点市场生成年度公共假日备货节点。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_uk",
|
||||
"GOV.UK 银行假日",
|
||||
"calendar",
|
||||
"英国",
|
||||
"https://www.gov.uk/bank-holidays.json",
|
||||
("england-and-wales", "events"),
|
||||
extractor="govuk_holidays",
|
||||
title_zh="英国银行假日",
|
||||
summary_zh="英国政府公开的英格兰和威尔士、苏格兰及北爱尔兰银行假日。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_uk_school_terms",
|
||||
"GOV.UK 学校学期校历入口",
|
||||
"calendar",
|
||||
"英国",
|
||||
"https://www.gov.uk/school-term-holiday-dates",
|
||||
("School term", "Find your child"),
|
||||
authority="英国政府官方校历入口",
|
||||
title_zh="英国秋季返校采购季",
|
||||
summary_zh="英国政府按地区提供学校学期与假期查询,返校日期必须按目标地区再次核对。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_de_school_holidays",
|
||||
"德国教育部长联席会议校历",
|
||||
"calendar",
|
||||
"德国",
|
||||
"https://www.kmk.org/service/ferienregelung.html",
|
||||
("Ferienregelung", "Kultusministerkonferenz"),
|
||||
authority="德国教育部长联席会议官方校历",
|
||||
title_zh="德国夏末返校采购季",
|
||||
summary_zh="德国各州开学和假期时间不同,官方校历用于确认目标州的返校采购窗口。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_us",
|
||||
"美国人事管理局联邦假日",
|
||||
"calendar",
|
||||
"美国",
|
||||
"https://www.opm.gov/policy-data-oversight/pay-leave/federal-holidays/",
|
||||
("Federal Holidays", "2027"),
|
||||
title_zh="美国联邦假日",
|
||||
summary_zh="美国联邦政府发布的法定假日安排,用于北美仓配、客服和促销节奏规划。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_japan",
|
||||
"日本政府法令英译数据库《国民祝日法》",
|
||||
"calendar",
|
||||
"日本",
|
||||
"https://www.japaneselawtranslation.go.jp/en/laws/view/4846/en",
|
||||
("Act on National Holidays", "Article 2"),
|
||||
authority="日本政府法令数据库",
|
||||
title_zh="日本国民祝日",
|
||||
summary_zh="日本政府法令数据库用于核对法定节日依据;具体年度日期由 Nager 日本数据生成并交叉展示。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_hk",
|
||||
"香港政府公众假期",
|
||||
"calendar",
|
||||
"中国香港",
|
||||
"https://www.gov.hk/en/about/abouthk/holiday/",
|
||||
("General holidays", "2027"),
|
||||
title_zh="香港公众假期",
|
||||
summary_zh="香港政府公布的公众假期,覆盖 2023 至 2027 年。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_aladhan",
|
||||
"AlAdhan 伊斯兰历",
|
||||
"calendar",
|
||||
"中东及穆斯林市场",
|
||||
"https://aladhan.com/islamic-calendar-api",
|
||||
("Islamic Calendar API", "Hijri", "Ramadan"),
|
||||
authority="结构化宗教历法数据",
|
||||
extractor="aladhan",
|
||||
title_zh="斋月与开斋节节点",
|
||||
summary_zh="通过公历与伊斯兰历转换识别斋月、开斋节、宰牲节和伊斯兰新年。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_nfl",
|
||||
"NFL Super Bowl",
|
||||
"calendar",
|
||||
"美国",
|
||||
"https://www.nfl.com/super-bowl/",
|
||||
("Super Bowl", "NFL"),
|
||||
title_zh="超级碗消费节点",
|
||||
summary_zh="超级碗会带动观赛派对、沙发毯、靠垫、桌布和主题家居软装需求。",
|
||||
start_date="2027-02-14",
|
||||
end_date="2027-02-14",
|
||||
venue="Levi's Stadium, Santa Clara, California",
|
||||
opportunity="提前 90 至 120 天准备运动主题盖毯、靠垫套、桌旗和派对软装。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_la28",
|
||||
"LA28 奥运会",
|
||||
"calendar",
|
||||
"美国及全球",
|
||||
"https://la28.org/en/games-plan/olympics.html",
|
||||
("Olympic", "LA28"),
|
||||
title_zh="洛杉矶 2028 奥运消费节点",
|
||||
summary_zh="大型国际体育赛事会放大酒店布草、观赛家居、城市主题和旅行场景需求。",
|
||||
start_date="2028-07-14",
|
||||
end_date="2028-07-30",
|
||||
venue="Los Angeles, California",
|
||||
opportunity="提前 12 至 18 个月规划酒店布草、运动主题家居和城市旅行系列。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_nrf",
|
||||
"NRF 冬季假日消费趋势",
|
||||
"calendar",
|
||||
"北美",
|
||||
"https://nrf.com/research-insights/holiday-data-and-trends/winter-holidays",
|
||||
("Winter Holiday", "consumer"),
|
||||
authority="美国零售联合会",
|
||||
title_zh="黑五与冬季假日消费季",
|
||||
summary_zh="NRF 的冬季假日消费资料用于校准感恩节、黑五、网一和圣诞季备货节奏。",
|
||||
),
|
||||
SourceSpec(
|
||||
"calendar_tmall",
|
||||
"阿里巴巴集团 11.11 官方活动回顾",
|
||||
"calendar",
|
||||
"中国及跨境华人市场",
|
||||
"https://www.alibabagroup.com/en-US/document-1926184987447525376",
|
||||
("11.11 Shopping Festival", "Taobao and Tmall"),
|
||||
authority="阿里巴巴集团官网",
|
||||
title_zh="双十一消费节点",
|
||||
summary_zh="双十一是中国电商年度核心促销节点,适合规划冬季床品、礼赠套装和高客单软装。",
|
||||
),
|
||||
|
||||
# 展会通:只保留主办方官网能够核实日期和地点的展会。
|
||||
SourceSpec(
|
||||
"expo_heimtextil",
|
||||
"Heimtextil Frankfurt",
|
||||
"exhibition",
|
||||
"德国",
|
||||
"https://heimtextil.messefrankfurt.com/frankfurt/en.html",
|
||||
("Heimtextil", "12", "January 2027"),
|
||||
title_zh="法兰克福国际家用及商用纺织品展",
|
||||
summary_zh="全球家用及商用纺织、纺织设计、趋势与可持续创新的重要展会。",
|
||||
start_date="2027-01-12",
|
||||
end_date="2027-01-15",
|
||||
venue="Messe Frankfurt, Frankfurt am Main",
|
||||
opportunity="适合床品、窗帘、装饰面料、地毯与酒店布草企业进行全球买家开发。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_intertextile_home",
|
||||
"Intertextile Shanghai Home Textiles",
|
||||
"exhibition",
|
||||
"中国",
|
||||
"https://intertextile-shanghai-hometextiles-autumn.hk.messefrankfurt.com/shanghai/zh-cn/planning-preparation/visiting.html",
|
||||
("中国国际家用纺织品", "观众资讯", "2026"),
|
||||
title_zh="中国国际家用纺织品及辅料博览会(秋冬)",
|
||||
summary_zh="亚洲重要家纺采购平台,覆盖床品、装饰布、窗帘、墙布、毛巾和家居软装。",
|
||||
start_date="2026-08-18",
|
||||
end_date="2026-08-20",
|
||||
venue="国家会展中心(上海)",
|
||||
opportunity="适合国内渠道、跨境采购商和新品首发,当前已进入临展跟进窗口。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_intertextile_apparel",
|
||||
"Intertextile Shanghai Apparel Fabrics",
|
||||
"exhibition",
|
||||
"中国",
|
||||
"https://intertextile-shanghai-apparel-fabrics-autumn.hk.messefrankfurt.com/shanghai/en.html",
|
||||
("Intertextile Shanghai Apparel Fabrics", "25", "August 2026"),
|
||||
title_zh="中国国际纺织面料及辅料博览会(秋冬)",
|
||||
summary_zh="面料、纱线、辅料、功能材料和可持续纺织技术的重要采购展。",
|
||||
start_date="2026-08-25",
|
||||
end_date="2026-08-27",
|
||||
venue="国家会展中心(上海)",
|
||||
opportunity="适合观察面料功能、色彩、工艺与服装趋势向家纺产品的迁移。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_texworld",
|
||||
"Texworld Apparel Sourcing Paris",
|
||||
"exhibition",
|
||||
"法国",
|
||||
"https://texworld-paris.fr.messefrankfurt.com/paris/en.html",
|
||||
("Texworld", "January 2027"),
|
||||
title_zh="巴黎 Texworld 面料与服装采购展",
|
||||
summary_zh="欧洲面料与服装采购平台,聚焦纺织材料、成衣制造、趋势和供应链。",
|
||||
start_date="2027-01-25",
|
||||
end_date="2027-01-27",
|
||||
venue="Paris Le Bourget, France",
|
||||
opportunity="适合开发欧洲面料买家并观察服装纹样、材质向家居软装的跨品类趋势。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_premiere_vision",
|
||||
"Première Vision Paris",
|
||||
"exhibition",
|
||||
"法国",
|
||||
"https://paris.premierevision.com/en",
|
||||
("Première Vision", "September 2026"),
|
||||
title_zh="巴黎第一视觉面料展",
|
||||
summary_zh="高端面料、纱线、皮革、辅料和创意制造的国际趋势与采购平台。",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-03",
|
||||
venue="Paris Nord Villepinte",
|
||||
opportunity="重点跟踪高端色彩、面料触感、图案与可持续材料信号。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_milano_unica",
|
||||
"Milano Unica",
|
||||
"exhibition",
|
||||
"意大利",
|
||||
"https://www.milanounica.it/en",
|
||||
("Milano Unica", "February 2027"),
|
||||
title_zh="米兰高端纺织面料展",
|
||||
summary_zh="意大利高端男女装面料与辅料展,适合观察高端材质和工艺方向。",
|
||||
start_date="2027-02-02",
|
||||
end_date="2027-02-04",
|
||||
venue="Fiera Milano (Rho)",
|
||||
opportunity="将高端服装面料的触感、提花和色彩趋势转译到床品与软装开发。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_maison_objet",
|
||||
"Maison&Objet Paris",
|
||||
"exhibition",
|
||||
"法国",
|
||||
"https://www.maison-objet.com/en/paris",
|
||||
("Maison&Objet", "September 2026"),
|
||||
title_zh="巴黎家居装饰博览会",
|
||||
summary_zh="覆盖家居、装饰、设计、礼品和生活方式的国际商业与趋势平台。",
|
||||
start_date="2026-09-10",
|
||||
end_date="2026-09-14",
|
||||
venue="Paris Nord Villepinte",
|
||||
opportunity="适合验证家纺产品在整体空间、礼赠和生活方式场景中的组合机会。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_high_point",
|
||||
"High Point Market",
|
||||
"exhibition",
|
||||
"美国",
|
||||
"https://www.highpointmarket.org/",
|
||||
("High Point Market", "Oct 17-21, 2026"),
|
||||
title_zh="美国高点家具家居展",
|
||||
summary_zh="北美大型家具与家居贸易市场,覆盖室内设计、软装、家具和采购渠道。",
|
||||
start_date="2026-10-17",
|
||||
end_date="2026-10-21",
|
||||
venue="High Point, North Carolina",
|
||||
opportunity="适合开发北美设计师、零售商与家具渠道的配套家纺订单。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_las_vegas_market",
|
||||
"Las Vegas Market",
|
||||
"exhibition",
|
||||
"美国",
|
||||
"https://www.andmore.com/markets",
|
||||
("Las Vegas Market", "home décor", "furniture"),
|
||||
authority="主办方 ANDMORE 官方市场目录",
|
||||
title_zh="拉斯维加斯家居礼品市场",
|
||||
summary_zh="美国西部家居、家具、礼品和生活方式采购市场。",
|
||||
start_date="2027-01-24",
|
||||
end_date="2027-01-28",
|
||||
venue="World Market Center Las Vegas",
|
||||
opportunity="适合开发家居零售、礼品渠道和区域分销商。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_index_dubai",
|
||||
"INDEX Dubai",
|
||||
"exhibition",
|
||||
"阿联酋",
|
||||
"https://www.indexexhibition.com/",
|
||||
("INDEX", "June 2027"),
|
||||
title_zh="迪拜室内设计与家居展 INDEX",
|
||||
summary_zh="中东及北非室内设计与家居供应链的重要商业展会。",
|
||||
start_date="2027-06-01",
|
||||
end_date="2027-06-03",
|
||||
venue="Dubai World Trade Centre",
|
||||
opportunity="重点关注酒店工程、遮光窗帘、奢华床品与耐高温场景材料。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_domotex_asia",
|
||||
"DOMOTEX asia/CHINAFLOOR",
|
||||
"exhibition",
|
||||
"中国",
|
||||
"https://www.domotexasiachinafloor.com/",
|
||||
("DOMOTEX asia", "May 26-28, 2027"),
|
||||
title_zh="亚洲地材及地毯展 DOMOTEX asia",
|
||||
summary_zh="亚太地材、地毯和空间材料供应链平台,包含地毯与纺织趋势。",
|
||||
start_date="2027-05-26",
|
||||
end_date="2027-05-28",
|
||||
venue="国家会展中心(上海)",
|
||||
opportunity="适合地毯、软装材料和家居空间整体解决方案企业。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_ciff_shanghai",
|
||||
"CIFF Shanghai",
|
||||
"exhibition",
|
||||
"中国",
|
||||
"https://en.ciff-sh.com/",
|
||||
("China International Furniture Fair", "2026"),
|
||||
title_zh="中国家博会(上海)",
|
||||
summary_zh="覆盖家具、家居、设计和上下游供应链的大型国际展会。",
|
||||
start_date="2026-09-05",
|
||||
end_date="2026-09-08",
|
||||
venue="国家会展中心(上海)",
|
||||
opportunity="适合将床品、窗帘、地毯和靠垫融入家具渠道与整屋软装方案。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_techtextil",
|
||||
"Techtextil Frankfurt",
|
||||
"exhibition",
|
||||
"德国",
|
||||
"https://techtextil.messefrankfurt.com/frankfurt/en.html",
|
||||
("Techtextil", "2028"),
|
||||
title_zh="法兰克福产业用纺织品及非织造布展",
|
||||
summary_zh="产业用纺织品、非织造布、功能材料与创新解决方案国际展会。",
|
||||
start_date="2028-04-04",
|
||||
end_date="2028-04-07",
|
||||
venue="Messe Frankfurt, Frankfurt am Main",
|
||||
opportunity="重点跟踪凉感、阻燃、抗菌、耐磨和可持续功能材料。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_home_textiles_sourcing",
|
||||
"Home Textiles Sourcing New York",
|
||||
"exhibition",
|
||||
"美国",
|
||||
"https://home-textiles-sourcing.us.messefrankfurt.com/new-york/en.html",
|
||||
("Home Textiles Sourcing", "June 15-17, 2027"),
|
||||
title_zh="纽约家用纺织品采购展",
|
||||
summary_zh="面向北美市场的家用纺织品、材料、制造和采购专业展。",
|
||||
start_date="2027-06-15",
|
||||
end_date="2027-06-17",
|
||||
venue="Javits Center, New York City",
|
||||
opportunity="适合直接对接北美家纺品牌、零售商和采购代理。",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_texpertise_network",
|
||||
"Texpertise Network 全球纺织展会日历",
|
||||
"exhibition",
|
||||
"全球",
|
||||
"https://texpertisenetwork.messefrankfurt.com/frankfurt/en/calendar.html",
|
||||
("Texpertise Network", "Trade fairs"),
|
||||
authority="Messe Frankfurt 主办方事件目录",
|
||||
extractor="messe_event_search",
|
||||
title_zh="Texpertise Network 全球纺织展会日历",
|
||||
summary_zh="汇集 Messe Frankfurt 在全球举办和授权的家纺、面料、服装与产业用纺织展会。",
|
||||
opportunity="按区域筛选官方展会,提前规划买家邀约、样品、签证、住宿和跨境运输。",
|
||||
change_detection="metadata",
|
||||
),
|
||||
SourceSpec(
|
||||
"expo_ufi_approved",
|
||||
"UFI 全球认证展会目录",
|
||||
"exhibition",
|
||||
"全球",
|
||||
"https://www.ufi.org/search-approved-events/?business_sector=TextilesApparelFashion",
|
||||
("Search Approved Events", "Textiles, Apparel, Fashion"),
|
||||
authority="UFI 全球展览业协会认证目录",
|
||||
extractor="ufi_approved",
|
||||
title_zh="UFI 认证纺织与家居展会目录",
|
||||
summary_zh="仅聚合 UFI 认证且公开未来日期、会场和主办官网的纺织、家居、室内与礼品采购展会。",
|
||||
opportunity="用 UFI 认证降低展会真伪核验成本,再从主办官网完成报名、观展和行程复核。",
|
||||
change_detection="metadata",
|
||||
),
|
||||
SourceSpec(
|
||||
"exhibition_ccbec",
|
||||
"CCBEC 深圳跨境电商展览会",
|
||||
"exhibition",
|
||||
"中国",
|
||||
"https://www.ccbec-shenzhen.com/",
|
||||
("深圳跨境电商展览会", "2026年9月16至18日", "参观预登记"),
|
||||
authority="Messe Frankfurt 与中国国际商会主办官网",
|
||||
title_zh="深圳跨境电商展览会 CCBEC 2026",
|
||||
summary_zh="覆盖跨境平台、家居日用、物流、支付、POD 与服务生态的官方行业展,适合家纺企业对接平台和跨境服务商。",
|
||||
start_date="2026-09-16",
|
||||
end_date="2026-09-18",
|
||||
venue="深圳国际会展中心(宝安)",
|
||||
opportunity="查看家居日用、平台、物流与POD专区,完成观众预登记并预约目标服务商。",
|
||||
platforms=("Amazon", "Temu", "Shopee", "Lazada", "TikTok Shop"),
|
||||
),
|
||||
SourceSpec(
|
||||
"exhibition_hktdc_home_textiles",
|
||||
"HKTDC Home InStyle",
|
||||
"exhibition",
|
||||
"中国香港",
|
||||
"https://www.hktdc.com/event/homeinstyle/en/fair-at-a-glance",
|
||||
("Home InStyle", "Fair at a Glance", "Exhibitor List"),
|
||||
authority="香港贸易发展局官方",
|
||||
title_zh="香港贸发局 Home InStyle 家居用品展",
|
||||
summary_zh="香港贸发局主办的家居、生活方式与设计采购展,提供参展商、产品目录和买家配对信息。",
|
||||
start_date="2027-04-27",
|
||||
end_date="2027-04-30",
|
||||
venue="香港会议展览中心",
|
||||
opportunity="适合家纺、软装和礼赠企业对接亚洲及海外买家;报名前以官方当届日期为准。",
|
||||
),
|
||||
|
||||
# 政策、合规与知识产权:优先政府、监管机构和平台官方入口。
|
||||
SourceSpec(
|
||||
"policy_ustr",
|
||||
"美国贸易代表办公室 USTR",
|
||||
"policy",
|
||||
"美国",
|
||||
"https://ustr.gov/issue-areas/enforcement/section-301-investigations",
|
||||
("Section 301", "USTR"),
|
||||
title_zh="美国 Section 301 与关税措施监测",
|
||||
summary_zh="跟踪美国贸易调查、附加关税和与强迫劳动相关的贸易措施。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_cbp",
|
||||
"美国海关与边境保护局 CBP",
|
||||
"policy",
|
||||
"美国",
|
||||
"https://www.cbp.gov/trade",
|
||||
("Trade", "Customs and Border Protection"),
|
||||
title_zh="美国进口、清关与小额包裹规则监测",
|
||||
summary_zh="跟踪美国进口申报、执法、小额包裹和供应链合规要求。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_hk_tid_de_minimis",
|
||||
"香港工贸署美国小额包裹通告",
|
||||
"policy",
|
||||
"美国",
|
||||
"https://www.tid.gov.hk/en/tradecircular/2026/ci5442026.html",
|
||||
("Indefinite Suspension", "De Minimis Exemption"),
|
||||
authority="香港特别行政区政府官方",
|
||||
title_zh="美国暂停小额包裹免税政策更新",
|
||||
summary_zh="香港工贸署汇总美国对邮递及其他运输方式货物暂停小额豁免的官方更新,适合跨境卖家核对生效安排与清关影响。",
|
||||
risk_level="高",
|
||||
policy_type="小额包裹与清关",
|
||||
markets=("美国", "中国出口"),
|
||||
action_zh="按运输方式核对美国小额包裹免税状态、承运商申报和落地税费,重新测算直邮家纺订单利润。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_eu_customs",
|
||||
"欧盟委员会税务与海关联盟",
|
||||
"policy",
|
||||
"欧盟",
|
||||
"https://taxation-customs.ec.europa.eu/customs_en",
|
||||
("Customs", "European Commission"),
|
||||
title_zh="欧盟海关与低价值包裹政策监测",
|
||||
summary_zh="跟踪欧盟海关改革、低价值包裹、进口税费和跨境电商申报变化。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_eu_textiles",
|
||||
"欧盟可持续与循环纺织品战略",
|
||||
"policy",
|
||||
"欧盟",
|
||||
"https://environment.ec.europa.eu/strategy/textiles-strategy_en",
|
||||
("Textiles Strategy", "European Commission"),
|
||||
title_zh="欧盟可持续与循环纺织品战略",
|
||||
summary_zh="关注生态设计、数字产品护照、生产者责任和绿色声明对纺织出口的影响。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_echa_chem",
|
||||
"ECHA CHEM",
|
||||
"policy",
|
||||
"欧盟",
|
||||
"https://chem.echa.europa.eu/",
|
||||
("ECHA CHEM",),
|
||||
title_zh="欧盟 REACH 化学物质合规检索",
|
||||
summary_zh="用于核查染整助剂、涂层和功能整理涉及的欧盟化学物质监管信息。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_cpsc",
|
||||
"美国消费品安全委员会召回 API",
|
||||
"policy",
|
||||
"美国",
|
||||
"https://www.saferproducts.gov/RestWebServices/Recall?format=json",
|
||||
("RecallID", "Products"),
|
||||
extractor="cpsc",
|
||||
title_zh="美国消费品召回监测",
|
||||
summary_zh="筛选床品、毯子、窗帘、地毯和儿童纺织相关的美国消费品召回。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_ftc_textile",
|
||||
"美国联邦贸易委员会纺织标签指南",
|
||||
"policy",
|
||||
"美国",
|
||||
"https://www.ftc.gov/business-guidance/resources/threading-your-way-through-labeling-requirements-under-textile-wool-acts",
|
||||
("Textile and Wool Acts", "Federal Trade Commission"),
|
||||
title_zh="美国纺织品与羊毛制品标签要求",
|
||||
summary_zh="覆盖纤维成分、原产地、制造商身份和广告披露等标签合规要求。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_uk_tariff",
|
||||
"英国贸易关税查询",
|
||||
"policy",
|
||||
"英国",
|
||||
"https://www.gov.uk/trade-tariff",
|
||||
("Trade Tariff", "commodity codes"),
|
||||
title_zh="英国商品编码、关税与增值税查询",
|
||||
summary_zh="用于核查家纺商品编码、关税税率、增值税和进出口限制。",
|
||||
risk_level="中",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_mofcom",
|
||||
"中国商务部政策发布",
|
||||
"policy",
|
||||
"中国",
|
||||
"https://www.mofcom.gov.cn/zcfb/",
|
||||
("政策", "商务"),
|
||||
title_zh="中国外贸与跨境电商政策监测",
|
||||
summary_zh="跟踪中国商务部发布的外贸、跨境电商、出口管制与市场政策。",
|
||||
risk_level="中",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_amazon",
|
||||
"Amazon Seller Central",
|
||||
"policy",
|
||||
"全球平台",
|
||||
"https://sellercentral.amazon.com/help/hub/reference/external/G200164330",
|
||||
("Amazon",),
|
||||
authority="平台官方",
|
||||
title_zh="Amazon 商品与卖家政策入口",
|
||||
summary_zh="跟踪商品合规、受限商品、账户绩效和平台履约规则变化。",
|
||||
risk_level="高",
|
||||
policy_type="平台规则",
|
||||
platforms=("Amazon",),
|
||||
markets=("全球",),
|
||||
action_zh="核对目标站点的商品合规、受限商品、标签和履约要求,并保存审核凭证。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_eaeu_ecommerce",
|
||||
"欧亚经济委员会 EEC",
|
||||
"policy",
|
||||
"欧亚经济联盟",
|
||||
"https://eec.eaeunion.org/en/news/sovet-eek-ustanovil-porog-besposhlinnogo-vvoza-i-stavki-tamozhennykh-poshlin-dlya-tovarov-vneshney-e/",
|
||||
("e-commerce", "Eurasian Economic Commission"),
|
||||
title_zh="欧亚经济联盟跨境电商免税门槛与关税监测",
|
||||
summary_zh="跟踪俄罗斯、哈萨克斯坦、白俄罗斯、亚美尼亚和吉尔吉斯斯坦共同适用的跨境电商进口门槛与关税规则。",
|
||||
risk_level="高",
|
||||
policy_type="关税与清关",
|
||||
markets=("俄罗斯", "哈萨克斯坦", "白俄罗斯", "亚美尼亚", "吉尔吉斯斯坦"),
|
||||
action_zh="按欧亚经济联盟统一规则复核包裹价值、商品编码、申报主体和税费承担方式。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_kazakhstan_customs",
|
||||
"哈萨克斯坦国家税务委员会",
|
||||
"policy",
|
||||
"哈萨克斯坦",
|
||||
"https://abai.kgd.gov.kz/en/content/rules-application-prohibitions-and-restrictions-when-moving-goods-across-customs-border",
|
||||
("customs border", "Kazakhstan"),
|
||||
title_zh="哈萨克斯坦海关禁限清单与跨境通关监测",
|
||||
summary_zh="核查商品进入欧亚经济联盟关境时适用的禁限措施、许可文件与申报要求。",
|
||||
risk_level="高",
|
||||
policy_type="禁限与清关",
|
||||
markets=("哈萨克斯坦", "中亚"),
|
||||
action_zh="出口前核对商品编码、成分、标签和可能需要的合格评定或许可文件。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_asean_trade",
|
||||
"ASEAN Secretariat ATIGA",
|
||||
"policy",
|
||||
"东盟",
|
||||
"https://asean.org/wp-content/uploads/2020/12/ASEAN-Trade-in-Goods-Agreement.pdf",
|
||||
("ASEAN", "Trade-in-Goods-Agreement"),
|
||||
extractor="pdf",
|
||||
title_zh="东盟货物贸易协定与区域原产地规则",
|
||||
summary_zh="以东盟秘书处公布的 ATIGA 协定为区域基础,结合各成员国海关复核关税、原产地规则和非关税措施。",
|
||||
risk_level="高",
|
||||
policy_type="区域贸易规则",
|
||||
markets=("新加坡", "印度尼西亚", "越南", "泰国", "马来西亚", "菲律宾"),
|
||||
action_zh="按目的国分别核对税则、原产地证、纺织标签和非关税措施,避免把东盟视为单一规则市场。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_singapore_customs",
|
||||
"Singapore Customs",
|
||||
"policy",
|
||||
"新加坡",
|
||||
"https://www.customs.gov.sg/doing-business/import-operations/import-procedures/importing-by-postal-or-courier-service/",
|
||||
("GST", "Low-Value"),
|
||||
title_zh="新加坡邮政、快递与低价值商品进口规则",
|
||||
summary_zh="跟踪邮政及快递进口、低价值商品 GST、许可证和申报流程。",
|
||||
risk_level="高",
|
||||
policy_type="税费与清关",
|
||||
markets=("新加坡",),
|
||||
action_zh="确认低价值商品 GST 征收方式、进口许可证、承运商申报责任和消费者结算口径。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_uae_vat",
|
||||
"阿联酋联邦税务局 FTA",
|
||||
"policy",
|
||||
"阿联酋",
|
||||
"https://tax.gov.ae/en/taxes/vat/guides.references.aspx",
|
||||
("Guides", "Federal Tax Authority"),
|
||||
title_zh="阿联酋增值税指南与跨境销售税务监测",
|
||||
summary_zh="核查阿联酋增值税注册、进口、电子商务和税务合规指南。",
|
||||
risk_level="高",
|
||||
policy_type="增值税",
|
||||
markets=("阿联酋", "中东"),
|
||||
action_zh="按销售主体、库存所在地和进口模式确认 VAT 注册、开票、申报与税费展示责任。",
|
||||
change_detection="metadata",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_saudi_ecommerce",
|
||||
"沙特税务、海关和扎卡特管理局 ZATCA",
|
||||
"policy",
|
||||
"沙特阿拉伯",
|
||||
"https://zatca.gov.sa/en/RulesRegulations/Taxes/Pages/customs-individual/e-stores-customers.aspx",
|
||||
("E-Store Customers", "customs duties"),
|
||||
title_zh="沙特跨境电商包裹关税与进口要求",
|
||||
summary_zh="跟踪沙特电商包裹的关税、增值税、申报、物流和商品限制。",
|
||||
risk_level="高",
|
||||
policy_type="关税与税费",
|
||||
markets=("沙特阿拉伯", "中东"),
|
||||
action_zh="发货前核算关税与增值税,核对商品限制、收件人信息、承运商申报和落地收费方式。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_tw_bsmi_textile",
|
||||
"台湾 BSMI 纺织品检验专区",
|
||||
"policy",
|
||||
"中国台湾",
|
||||
"https://www.bsmi.gov.tw/wSite/np?ctNode=8871&mp=1",
|
||||
("經濟部標準檢驗局", "紡織品", "商品檢驗"),
|
||||
authority="台湾经济部标准检验局官方",
|
||||
title_zh="台湾纺织品检验与标示规则",
|
||||
summary_zh="核查寝具、毛巾、服装与其他纺织品进入台湾市场时适用的商品检验、中文标示和安全要求。",
|
||||
risk_level="高",
|
||||
policy_type="纺织品检验与标示",
|
||||
markets=("中国台湾",),
|
||||
action_zh="按产品品类确认是否属于应施检验商品,并核对中文标示、成分、原产地与检验文件。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_saudi_saso_textile",
|
||||
"沙特 SASO 纺织产品技术法规",
|
||||
"policy",
|
||||
"沙特阿拉伯",
|
||||
"https://www.saso.gov.sa/en/laws-and-regulations/technical_regulations/pages/regulation22.aspx",
|
||||
("Technical Regulation for Textile Products", "Official government website", "SASO"),
|
||||
authority="沙特标准、计量与质量组织官方",
|
||||
title_zh="沙特纺织产品技术法规",
|
||||
summary_zh="沙特纺织产品技术法规覆盖安全、标签、符合性评定与市场准入,是家纺进入沙特市场的基础合规依据。",
|
||||
risk_level="高",
|
||||
policy_type="纺织品技术法规",
|
||||
markets=("沙特阿拉伯", "中东"),
|
||||
action_zh="逐项核对阿拉伯语标签、纤维成分、符合性评定和SABER相关文件,并由进口商确认当前版本。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_ebay_export",
|
||||
"eBay Export",
|
||||
"policy",
|
||||
"全球平台",
|
||||
"https://export.ebay.com/en/regulations/",
|
||||
("eBay", "regulations"),
|
||||
authority="平台官方",
|
||||
title_zh="eBay 跨境销售法规与合规入口",
|
||||
summary_zh="跟踪 eBay 跨境销售涉及的产品法规、税务、受限商品和目的国要求。",
|
||||
risk_level="高",
|
||||
policy_type="平台规则",
|
||||
platforms=("eBay",),
|
||||
markets=("美国", "英国", "欧盟", "全球"),
|
||||
action_zh="按刊登站点和送达国家核对产品法规、税务、受限商品与刊登信息要求。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_tiktok_shop",
|
||||
"TikTok Shop Academy",
|
||||
"policy",
|
||||
"全球平台",
|
||||
"https://seller-us.tiktok.com/university/home?identity=1&menu=policy&role=seller",
|
||||
("TikTok Shop Academy",),
|
||||
authority="平台官方",
|
||||
title_zh="TikTok Shop 卖家政策中心",
|
||||
summary_zh="跟踪 TikTok Shop 商品发布、内容、履约、知识产权和账户治理规则。",
|
||||
risk_level="高",
|
||||
policy_type="平台规则",
|
||||
platforms=("TikTok Shop",),
|
||||
markets=("美国", "英国", "东南亚", "全球"),
|
||||
action_zh="按目标站点核对家纺类目准入、商品素材、内容宣传、知识产权和履约指标。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_walmart_marketplace",
|
||||
"Walmart Marketplace",
|
||||
"policy",
|
||||
"美国平台",
|
||||
"https://marketplace.walmart.com/",
|
||||
("Walmart Marketplace", "seller"),
|
||||
authority="平台官方",
|
||||
title_zh="Walmart Marketplace 卖家与商品合规入口",
|
||||
summary_zh="跟踪 Walmart Marketplace 的卖家准入、商品、履约和零售标准要求。",
|
||||
risk_level="高",
|
||||
policy_type="平台规则",
|
||||
platforms=("Walmart Marketplace",),
|
||||
markets=("美国",),
|
||||
action_zh="核查企业准入、产品标识、责任险、物流履约和家纺商品安全要求。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_mercadolibre",
|
||||
"Mercado Libre Global Selling",
|
||||
"policy",
|
||||
"拉丁美洲平台",
|
||||
"https://global-selling.mercadolibre.com/learning-center",
|
||||
("Mercado Libre", "Learning Center"),
|
||||
authority="平台官方",
|
||||
title_zh="Mercado Libre 跨境卖家规则与运营入口",
|
||||
summary_zh="跟踪拉丁美洲主要站点的刊登、物流、税务、产品限制和卖家运营规则。",
|
||||
risk_level="高",
|
||||
policy_type="平台规则",
|
||||
platforms=("Mercado Libre",),
|
||||
markets=("巴西", "墨西哥", "智利", "哥伦比亚"),
|
||||
action_zh="按目标国家分别核对税务、禁限商品、刊登语言、物流方案和售后时效。",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_wipo_branddb",
|
||||
"WIPO 全球品牌数据库",
|
||||
"policy",
|
||||
"全球",
|
||||
"https://www.wipo.int/reference/en/branddb/",
|
||||
("Global Brand Database", "WIPO"),
|
||||
title_zh="WIPO 全球商标近似检索",
|
||||
summary_zh="用于跨境上新前检索文字、图形和品牌要素,降低商标侵权风险。",
|
||||
risk_level="高",
|
||||
),
|
||||
SourceSpec(
|
||||
"policy_euipo_search",
|
||||
"EUIPO 知识产权检索",
|
||||
"policy",
|
||||
"欧盟",
|
||||
"https://www.euipo.europa.eu/en/search-ip",
|
||||
("Searches", "EUIPO"),
|
||||
title_zh="EUIPO 商标与外观设计检索",
|
||||
summary_zh="用于欧盟市场的商标、外观设计和权利人信息检索。",
|
||||
risk_level="高",
|
||||
),
|
||||
|
||||
# 趋势与标准:补充现有 17 个图文趋势源的权威结论层。
|
||||
SourceSpec(
|
||||
"standard_pantone",
|
||||
"Pantone Color of the Year",
|
||||
"standard",
|
||||
"全球",
|
||||
"https://www.pantone.com/color-of-the-year/2026",
|
||||
("Color of the Year 2026", "Cloud Dancer"),
|
||||
authority="色彩体系机构",
|
||||
title_zh="Pantone 2026 年度代表色:Cloud Dancer",
|
||||
summary_zh="将年度色彩信号与现有家纺趋势图文结合,辅助床品、窗帘和软装配色开发。",
|
||||
),
|
||||
SourceSpec(
|
||||
"standard_textile_exchange",
|
||||
"Textile Exchange Materials Benchmark",
|
||||
"standard",
|
||||
"全球",
|
||||
"https://textileexchange.org/app/uploads/2025/04/Materials-Benchmark-Survey-Guide-2025.pdf",
|
||||
("Materials-Benchmark-Survey-Guide-2025.pdf",),
|
||||
authority="国际纺织可持续组织",
|
||||
extractor="pdf",
|
||||
title_zh="纺织材料与可持续标准动态",
|
||||
summary_zh="以 Textile Exchange 官方材料基准指南核对优选纤维、再生材料、气候目标和供应链可追溯性要求。",
|
||||
),
|
||||
SourceSpec(
|
||||
"standard_oeko_tex",
|
||||
"OEKO-TEX Newsroom",
|
||||
"standard",
|
||||
"全球",
|
||||
"https://www.oeko-tex.com/en/news",
|
||||
("OEKO-TEX", "Newsroom"),
|
||||
authority="纺织品检测认证体系",
|
||||
title_zh="OEKO-TEX 认证与限值动态",
|
||||
summary_zh="跟踪有害物质检测、认证标准和纺织供应链合规更新。",
|
||||
),
|
||||
SourceSpec(
|
||||
"standard_aatcc",
|
||||
"AATCC News & Read",
|
||||
"standard",
|
||||
"美国及全球",
|
||||
"https://www.aatcc.org/news-read/insights",
|
||||
("AATCC", "News"),
|
||||
authority="纺织化学家与染色家协会",
|
||||
title_zh="AATCC 纺织测试与标准动态",
|
||||
summary_zh="关注色牢度、耐久性、舒适性、功能整理和纺织测试方法更新。",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def validate_registry() -> None:
|
||||
"""在抓取前验证注册表的数量、标识和地址完整性。"""
|
||||
if len(SOURCES) != 59:
|
||||
raise ValueError(f"权威源数量必须为 59,当前为 {len(SOURCES)}")
|
||||
ids = [source.id for source in SOURCES]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("权威源 id 存在重复")
|
||||
if any(not source.url.startswith("https://") for source in SOURCES):
|
||||
raise ValueError("所有权威源必须使用 HTTPS")
|
||||
@@ -0,0 +1,201 @@
|
||||
"""生成经营者首屏使用的今日必看混排。"""
|
||||
|
||||
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]
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""家纺情报的中文标题翻译与术语校准。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
if __package__:
|
||||
from .update_textile_trends import load_zh_cache, save_zh_cache, translate_to_zh_cn
|
||||
else:
|
||||
from update_textile_trends import load_zh_cache, save_zh_cache, translate_to_zh_cn
|
||||
|
||||
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0 Safari/537.36"
|
||||
)
|
||||
CALENDAR_TITLE_OVERRIDES = {
|
||||
"New Year's Day": "元旦",
|
||||
"New Year’s Day": "元旦",
|
||||
"New Year's Eve": "跨年夜",
|
||||
"St. Stephen's Day": "圣斯蒂芬节",
|
||||
"Christmas Eve": "平安夜",
|
||||
"Pentecost": "圣灵降临节",
|
||||
"Immaculate Conception": "圣母无染原罪节",
|
||||
"Maundy Thursday": "濯足节",
|
||||
"Chuseok": "韩国秋夕",
|
||||
"All Saints Day": "诸圣节",
|
||||
"All Saints' Day Eve": "诸圣节前夜",
|
||||
"All Souls' Day": "追思亡者节",
|
||||
"Armistice Day": "停战纪念日",
|
||||
"Summer bank holiday": "夏季银行假日",
|
||||
"Early May bank holiday": "五月初银行假日",
|
||||
"Spring bank holiday": "春季银行假日",
|
||||
"May Day": "五一劳动节",
|
||||
"Good Friday": "耶稣受难日",
|
||||
"Holy Saturday": "圣周六",
|
||||
"Easter Monday": "复活节星期一",
|
||||
"Christmas Day": "圣诞节",
|
||||
"Boxing Day": "节礼日",
|
||||
"Saint Patrick's Day": "圣帕特里克节",
|
||||
"St Patrick’s Day": "圣帕特里克节",
|
||||
"National Heroes Day": "国家英雄日",
|
||||
"National Day for Truth and Reconciliation": "真相与和解国家纪念日",
|
||||
"National Day of Spain": "西班牙国庆日",
|
||||
"German Unity Day": "德国统一日",
|
||||
"National Foundation Day": "建国纪念日",
|
||||
"Hangul Day": "韩文日",
|
||||
"Labour Thanksgiving Day": "勤劳感谢之日",
|
||||
"Thanksgiving": "感恩节",
|
||||
"Remembrance Day": "阵亡将士纪念日",
|
||||
"Veterans Day": "退伍军人节",
|
||||
"Day of Reconciliation": "和解日",
|
||||
"Day of Goodwill": "友善日",
|
||||
"Deepavali": "屠妖节",
|
||||
"Hari Raya Puasa": "开斋节",
|
||||
"Hari Raya Haji": "哈芝节",
|
||||
"Vesak Day": "卫塞节",
|
||||
"Eid al-Fitr First Day (Tentative Date)": "开斋节首日(暂定)",
|
||||
"Eid al-Fitr Second Day (Tentative Date)": "开斋节次日(暂定)",
|
||||
"Eid al-Fitr Third Day (Tentative Date)": "开斋节第三日(暂定)",
|
||||
"Eid al-Adha First Day (Tentative Date)": "宰牲节首日(暂定)",
|
||||
"Eid al-Adha Second Day (Tentative Date)": "宰牲节次日(暂定)",
|
||||
"Eid al-Adha Third Day (Tentative Date)": "宰牲节第三日(暂定)",
|
||||
"Eid al-Adha Fourth Day (Tentative Date)": "宰牲节第四日(暂定)",
|
||||
"Anzac Day": "澳新军团日",
|
||||
"Waitangi Day": "怀唐伊日",
|
||||
"Matariki": "毛利新年 Matariki",
|
||||
"Juneteenth National Independence Day": "六月节国家独立日",
|
||||
}
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return " ".join((value or "").replace("\u00a0", " ").split())
|
||||
|
||||
|
||||
def refine_calendar_translation(value: str) -> str:
|
||||
replacements = {
|
||||
"假定日期": "暂定日期",
|
||||
"第一天(暂定日期)": "首日(暂定)",
|
||||
"第二天(暂定日期)": "次日(暂定)",
|
||||
"第三天(暂定日期)": "第三日(暂定)",
|
||||
"第四天(暂定日期)": "第四日(暂定)",
|
||||
"银行关闭日": "银行休业日",
|
||||
"家庭日": "家庭日",
|
||||
}
|
||||
output = normalize_text(value)
|
||||
for source, target in replacements.items():
|
||||
output = output.replace(source, target)
|
||||
return output
|
||||
|
||||
|
||||
def translate_calendar_titles(items: list[dict[str, Any]], output_dir: Path, max_new: int = 260) -> None:
|
||||
cache = load_zh_cache(output_dir)
|
||||
session = requests.Session()
|
||||
session.headers.update({"User-Agent": BROWSER_UA, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"})
|
||||
translated_count = 0
|
||||
cache_changed = False
|
||||
for item in items:
|
||||
current = normalize_text(str(item.get("title") or ""))
|
||||
if not current:
|
||||
continue
|
||||
english = normalize_text(str(item.get("title_english") or current))
|
||||
translated = CALENDAR_TITLE_OVERRIDES.get(english)
|
||||
if translated:
|
||||
if item.get("source_id") != "calendar_nager" or not item.get("title_original"):
|
||||
item["title_original"] = current
|
||||
item["title"] = translated
|
||||
item["translation_status"] = "已按权威节日名称校准并保留当地名称"
|
||||
continue
|
||||
if re.search(r"[\u4e00-\u9fff]", current):
|
||||
continue
|
||||
translated = cache.get(english)
|
||||
if not translated and translated_count < max_new:
|
||||
translated = translate_to_zh_cn(session, english)
|
||||
if translated:
|
||||
cache[english] = translated
|
||||
translated_count += 1
|
||||
cache_changed = True
|
||||
if not translated or not re.search(r"[\u4e00-\u9fff]", translated):
|
||||
translated = f"{english}({item.get('region') or '当地'}公共假日)"
|
||||
item["translation_status"] = "保留官方名称并补充中文语境"
|
||||
else:
|
||||
translated = refine_calendar_translation(translated)
|
||||
item["translation_status"] = "已翻译并保留官方名称"
|
||||
if item.get("source_id") != "calendar_nager" or not item.get("title_original"):
|
||||
item["title_original"] = current
|
||||
item["title"] = translated
|
||||
if cache_changed:
|
||||
save_zh_cache(output_dir, cache)
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""识别官网语种,并生成简体中文网页翻译链接。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
|
||||
TRADITIONAL_MARKERS = "體臺灣與為這個開會網頁讀覽資訊國際廣東學術裝趨關寧設計藝應將來產品時們業務發後點選專報導現場還裡讓從對實現線數據萬價銷購貿環驗證標準"
|
||||
SIMPLIFIED_MARKERS = "体台湾与为这个开会网页读览资讯国际广东学术装趋关宁设计艺应将来产品时们业务发后点选专报道现场还里让从对实现线数据万价销购贸环验证标准"
|
||||
GOOGLE_TRANSLATE_BLOCKED_HOSTS = {
|
||||
"abai.kgd.gov.kz",
|
||||
"cpsc.gov",
|
||||
"www.cpsc.gov",
|
||||
"www.zatca.gov.sa",
|
||||
"zatca.gov.sa",
|
||||
}
|
||||
YANDEX_TRANSLATE_BLOCKED_HOSTS = {"abai.kgd.gov.kz"}
|
||||
|
||||
|
||||
def google_translation_url(url: str) -> str:
|
||||
"""Google 网页翻译可显式指定简体中文,作为主要阅读入口。"""
|
||||
return f"https://translate.google.com/translate?sl=auto&tl=zh-CN&u={quote(url, safe='')}"
|
||||
|
||||
|
||||
def yandex_translation_url(url: str, source_language: str = "en") -> str:
|
||||
"""使用稳定入口,让 Yandex 自行生成带时效令牌的 TurboPages 译文地址。"""
|
||||
source = (source_language or "en").lower().split("-")[0]
|
||||
return f"https://translate.yandex.com/translate?url={quote(url, safe='')}&lang={source}-zh"
|
||||
|
||||
|
||||
def detect_text_language(text: str = "", declared_language: str = "") -> str:
|
||||
"""用页面声明优先、字符特征兜底,返回前端可直接判断的语种代码。"""
|
||||
declared = (declared_language or "").strip().lower().replace("_", "-")
|
||||
if declared:
|
||||
if declared.startswith(("zh-cn", "zh-sg", "zh-hans")):
|
||||
return "zh-CN"
|
||||
if declared.startswith(("zh-tw", "zh-hk", "zh-mo", "zh-hant")):
|
||||
return "zh-Hant"
|
||||
return declared.split("-")[0]
|
||||
|
||||
value = text or ""
|
||||
han_count = len(re.findall(r"[\u3400-\u9fff]", value))
|
||||
latin_count = len(re.findall(r"[A-Za-z]", value))
|
||||
if han_count >= 8 and han_count >= latin_count * 0.2:
|
||||
traditional_count = sum(value.count(char) for char in TRADITIONAL_MARKERS)
|
||||
simplified_count = sum(value.count(char) for char in SIMPLIFIED_MARKERS)
|
||||
return "zh-Hant" if traditional_count >= 2 and traditional_count > simplified_count else "zh-CN"
|
||||
return "en"
|
||||
|
||||
|
||||
def url_language_hint(url: str) -> str:
|
||||
value = (url or "").lower()
|
||||
if re.search(r"(?:[?&](?:lang|language|localetype)=|/)(?:zh-cn|zh-hans|cn)(?:\.html?)?(?:[&#/?]|$)", value):
|
||||
return "zh-CN"
|
||||
if re.search(r"(?:[?&](?:lang|language|localetype)=|/)(?:zh-tw|zh-hant|tw|hk)(?:\.html?)?(?:[&#/?]|$)", value):
|
||||
return "zh-Hant"
|
||||
host = (urlparse(url).hostname or "").lower().rstrip(".")
|
||||
host_languages = {
|
||||
"bizevent.ccpit.org": "zh-CN",
|
||||
"www.coupang.com": "ko",
|
||||
"www.mercadolibre.com": "es",
|
||||
}
|
||||
if host in host_languages:
|
||||
return host_languages[host]
|
||||
for suffix, language in (
|
||||
((".co.jp", ".jp"), "ja"),
|
||||
((".ru",), "ru"),
|
||||
((".de",), "de"),
|
||||
((".fr",), "fr"),
|
||||
((".pl",), "pl"),
|
||||
((".kr",), "ko"),
|
||||
((".it",), "it"),
|
||||
((".es",), "es"),
|
||||
((".com.mx",), "es"),
|
||||
((".nl",), "nl"),
|
||||
((".com.tr", ".tr"), "tr"),
|
||||
):
|
||||
if host.endswith(suffix):
|
||||
return language
|
||||
return ""
|
||||
|
||||
|
||||
def is_simplified_chinese_site(url: str, source_language: str = "", source_text: str = "") -> bool:
|
||||
"""简体中文内容和 .cn 域名不生成翻译入口。"""
|
||||
host = (urlparse(url).hostname or "").lower().rstrip(".")
|
||||
if host.endswith(".cn"):
|
||||
return True
|
||||
declared = detect_text_language("", source_language) if source_language else ""
|
||||
content_language = detect_text_language(source_text) if source_text.strip() else ""
|
||||
if declared in ("zh-CN", "zh-hans", "zh-sg") or content_language == "zh-CN":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def needs_simplified_chinese_translation(language: str) -> bool:
|
||||
return (language or "").lower() not in ("zh-cn", "zh-hans", "zh-sg")
|
||||
|
||||
|
||||
def google_translation_supported(url: str) -> bool:
|
||||
"""记录已验证会拒绝 Google 网页代理的站点,避免发布无效按钮。"""
|
||||
host = (urlparse(url).hostname or "").lower().rstrip(".")
|
||||
return host not in GOOGLE_TRANSLATE_BLOCKED_HOSTS
|
||||
|
||||
|
||||
def yandex_translation_supported(url: str) -> bool:
|
||||
"""记录已验证会返回 Yandex 翻译错误页的站点。"""
|
||||
host = (urlparse(url).hostname or "").lower().rstrip(".")
|
||||
return host not in YANDEX_TRANSLATE_BLOCKED_HOSTS
|
||||
|
||||
|
||||
def translation_fields(url: str, source_language: str = "", source_text: str = "") -> dict[str, Any]:
|
||||
"""只为非简体中文页面输出翻译入口,避免中文官网出现无意义按钮。"""
|
||||
if not url.startswith("https://"):
|
||||
return {}
|
||||
language = url_language_hint(url) or detect_text_language(source_text, source_language)
|
||||
if is_simplified_chinese_site(url, source_language, source_text):
|
||||
language = "zh-CN"
|
||||
needs_translation = needs_simplified_chinese_translation(language)
|
||||
google_supported = google_translation_supported(url)
|
||||
yandex_supported = yandex_translation_supported(url)
|
||||
return {
|
||||
"source_language": language,
|
||||
"needs_translation": needs_translation,
|
||||
"google_translation_supported": google_supported,
|
||||
"yandex_translation_supported": yandex_supported,
|
||||
"translation_url": google_translation_url(url) if needs_translation and google_supported else "",
|
||||
"translation_provider": "Google 简体中文网页翻译" if needs_translation and google_supported else "",
|
||||
"translation_fallback_url": yandex_translation_url(url, language) if needs_translation and yandex_supported else "",
|
||||
"translation_fallback_provider": "Yandex 网页翻译" if needs_translation and yandex_supported else "",
|
||||
"translation_notice": (
|
||||
("该官网已验证不支持 Google 与 Yandex 网页代理;请使用本站中文摘要并打开官方原文。"
|
||||
if needs_translation and not google_supported and not yandex_supported else
|
||||
"该官网已验证不支持 Google 网页代理;可尝试 Yandex 译文入口,或使用本站中文摘要并打开官方原文。"
|
||||
if needs_translation and not google_supported else
|
||||
"机器翻译仅用于中文辅助阅读;报名、付款或提交资料请返回官方原文页面。")
|
||||
if needs_translation else "官网已识别为简体中文,无需机器翻译。"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""三笙AI情报局的 SEO/GEO 统一词表与页面回答规则。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
BRAND_NAME = "三笙AI情报局"
|
||||
ENTITY_DEFINITION = (
|
||||
"三笙AI情报局是面向家纺、纺织品与服装跨境经营团队的静态情报站,"
|
||||
"把官方日期、展会、政策、平台和趋势资料整理成可核验的中文决策入口。"
|
||||
)
|
||||
|
||||
KEYWORD_CLUSTERS = {
|
||||
"brand": ("三笙AI", "三笙AI情报局"),
|
||||
"core": ("家纺AI", "家纺情报", "家纺跨境平台", "跨境家纺平台"),
|
||||
"task": ("家纺备货日历", "家纺展会", "家纺政策", "家纺平台渠道", "家纺趋势"),
|
||||
}
|
||||
|
||||
SECTION_INTENTS = {
|
||||
"calendar": "回答家纺跨境团队何时备货、面向哪些国家和平台、下一步做什么。",
|
||||
"exhibitions": "回答哪些家纺展会值得参加、官方报名入口在哪里、参展前要准备什么。",
|
||||
"policies": "回答哪些跨境政策或平台规则发生变化、影响谁、今天需要采取什么动作。",
|
||||
"standards": "回答色彩、材料、检测和趋势信号如何转化为家纺产品与打样任务。",
|
||||
"platforms": "回答哪些跨境平台适合家纺经营、进入条件是什么、应从哪个官方入口核验。",
|
||||
"activities": "回答家纺跨境活动的时间、地区、活动类型和官方报名路径。",
|
||||
}
|
||||
|
||||
|
||||
def keywords_for(section: str = "", item: dict[str, Any] | None = None) -> list[str]:
|
||||
values = [*KEYWORD_CLUSTERS["brand"], *KEYWORD_CLUSTERS["core"], *KEYWORD_CLUSTERS["task"]]
|
||||
if section in SECTION_INTENTS:
|
||||
values.append(SECTION_INTENTS[section])
|
||||
if item:
|
||||
values.extend(str(item.get(key) or "") for key in ("title", "region", "source"))
|
||||
values.extend(str(value) for value in item.get("platforms") or [])
|
||||
return list(dict.fromkeys(value.strip() for value in values if value.strip()))
|
||||
|
||||
|
||||
def answer_first(section: str, item: dict[str, Any] | None = None) -> str:
|
||||
if item:
|
||||
conclusion = item.get("next_action") or item.get("action") or item.get("opportunity") or item.get("summary")
|
||||
return f"先看结论:{item.get('title') or '这条家纺情报'}。{conclusion or SECTION_INTENTS.get(section, '')}"
|
||||
return f"快速答案:{SECTION_INTENTS.get(section, ENTITY_DEFINITION)}"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""启动本地静态服务并自动选择可用端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
import webbrowser
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def available_port(start: int) -> int:
|
||||
for port in range(start, start + 20):
|
||||
with socket.socket() as probe:
|
||||
try:
|
||||
probe.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("8765 至 8784 均被占用")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="启动三笙AI情报局本地站点")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if not (ROOT / "dist" / "index.html").is_file():
|
||||
raise FileNotFoundError("dist/index.html 不存在,请先运行 update.bat")
|
||||
port = available_port(args.port)
|
||||
handler = partial(SimpleHTTPRequestHandler, directory=str(ROOT / "dist"))
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
||||
url = f"http://127.0.0.1:{port}/"
|
||||
print(f"[启动成功] {url}")
|
||||
webbrowser.open(url)
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[已停止] 本地服务已关闭")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[启动失败] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
"""家纺与时装 RSS 来源及相关性筛选。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
FASHION_TREND_FEEDS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "Hypebeast 中文",
|
||||
"url": "https://hypebeast.cn/fashion/feed",
|
||||
"category": "时装趋势",
|
||||
"category_key": "fashion_trends",
|
||||
},
|
||||
{
|
||||
"name": "GQ Hong Kong",
|
||||
"url": "https://gqhongkong.com/style/feed/",
|
||||
"category": "时装趋势",
|
||||
"category_key": "fashion_trends",
|
||||
},
|
||||
{
|
||||
"name": "Citta Bella 都会佳人",
|
||||
"url": "https://cittabella.my/category/fashion/feed/",
|
||||
"category": "时装趋势",
|
||||
"category_key": "fashion_trends",
|
||||
},
|
||||
]
|
||||
|
||||
CURATED_TREND_PAGES: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "ASInsight",
|
||||
"url": "https://www.asinsight.com/report/US/cooling-duvet-cover",
|
||||
"category": "趋势博客",
|
||||
"category_key": "trend_blogs",
|
||||
},
|
||||
]
|
||||
|
||||
FREE_TREND_FEEDS: list[dict[str, Any]] = [
|
||||
{"name": "Dezeen - Textiles", "url": "https://www.dezeen.com/tag/textiles/feed/"},
|
||||
{"name": "Dezeen - Fashion", "url": "https://www.dezeen.com/tag/fashion-tag/feed/"},
|
||||
{"name": "Dezeen - Clothing", "url": "https://www.dezeen.com/tag/clothing/feed/"},
|
||||
{"name": "Dezeen - Homeware", "url": "https://www.dezeen.com/tag/homeware/feed/"},
|
||||
{"name": "ArchDaily - Fabrics", "url": "https://www.archdaily.com/tag/fabrics/feed/"},
|
||||
{"name": "Trendland", "url": "https://trendland.com/feed/"},
|
||||
{"name": "Design Milk", "url": "https://design-milk.com/feed/"},
|
||||
{"name": "Designboom", "url": "https://www.designboom.com/feed/"},
|
||||
{"name": "Fibre2Fashion - Textiles", "url": "https://feeds.feedburner.com/fibre2fashion/textile-news"},
|
||||
{"name": "Fibre2Fashion - Fashion", "url": "https://feeds.feedburner.com/fibre2fashion/fashion-news"},
|
||||
] + FASHION_TREND_FEEDS
|
||||
|
||||
FASHION_REFERENCE_KEYWORDS: tuple[str, ...] = (
|
||||
"runway", "lookbook", "fashion week", "couture", "ready-to-wear", "menswear", "womenswear",
|
||||
"tailoring", "apparel", "garment", "outfit", "textile", "fabric",
|
||||
"embroidery", "knit", "woven", "jacquard", "denim", "dress", "shirt", "jacket", "coat",
|
||||
"trousers", "pants", "skirt", "suit", "home textile", "bedding", "bed linen",
|
||||
"時裝", "时装", "秀場", "秀场", "時裝週", "时装周", "大秀", "春夏", "秋冬",
|
||||
"男裝", "男装", "女裝", "女装", "成衣", "高訂", "高定", "型錄", "型录",
|
||||
"服裝", "服装", "服飾", "服饰", "穿搭", "剪裁", "廓形", "面料", "布料",
|
||||
"織物", "织物", "印花", "刺繡", "刺绣", "針織", "针织", "提花", "牛仔",
|
||||
"西裝", "西装", "襯衫", "衬衫", "外套", "洋裝", "洋装", "紡織", "纺织",
|
||||
"家紡", "家纺", "床品", "床上用品",
|
||||
)
|
||||
|
||||
SURFACE_DESIGN_KEYWORDS: tuple[str, ...] = (
|
||||
"pattern", "texture", "motif", "surface design", "紋樣", "纹样", "圖案", "图案",
|
||||
"紋理", "纹理", "肌理", "流行趨勢", "流行趋势",
|
||||
)
|
||||
|
||||
NON_TEXTILE_TITLE_KEYWORDS: tuple[str, ...] = (
|
||||
"watch", "watches", "jewelry", "jewellery", "eyewear", "sunglasses", "handbag", "footwear",
|
||||
"sneaker", "perfume", "fragrance", "cosmetic", "腕表", "腕錶", "手表", "手錶", "珠宝", "珠寶",
|
||||
"首饰", "首飾", "眼镜", "眼鏡", "太阳镜", "太陽鏡", "包袋", "手袋", "手包", "鞋", "靴",
|
||||
"香水", "香氛", "彩妆", "彩妝", "美妆", "美妝",
|
||||
)
|
||||
|
||||
|
||||
def is_fashion_reference(title: str, content: str) -> bool:
|
||||
"""判断内容是否具备服装、秀场或纺织设计参考价值。"""
|
||||
title_text = title.lower()
|
||||
haystack = f"{title} {content}".lower()
|
||||
|
||||
def contains(text: str, keyword: str) -> bool:
|
||||
token = keyword.lower()
|
||||
if token.isascii():
|
||||
return re.search(rf"(?<![a-z]){re.escape(token)}(?![a-z])", text) is not None
|
||||
return token in text
|
||||
|
||||
if any(contains(title_text, keyword) for keyword in NON_TEXTILE_TITLE_KEYWORDS):
|
||||
return False
|
||||
if any(contains(haystack, keyword) for keyword in FASHION_REFERENCE_KEYWORDS):
|
||||
return True
|
||||
return any(contains(title_text, keyword) for keyword in SURFACE_DESIGN_KEYWORDS)
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""抓取、校验、构建成功后再替换正式数据和站点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA_FILES = (
|
||||
"textile-intelligence.json",
|
||||
"textile-trends.json",
|
||||
"textile-intelligence-source-status.json",
|
||||
"textile-zh-cache.json",
|
||||
)
|
||||
MAX_SNAPSHOTS = 14
|
||||
MAX_SNAPSHOT_BYTES = 120 * 1024 * 1024
|
||||
|
||||
|
||||
def run(*parts: str) -> None:
|
||||
subprocess.run(parts, cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else ""
|
||||
|
||||
|
||||
def validate(data_dir: Path) -> dict:
|
||||
payload = json.loads((data_dir / "textile-intelligence.json").read_text(encoding="utf-8"))
|
||||
if payload.get("source_target") != payload.get("source_success"):
|
||||
raise RuntimeError("权威源未全部通过,拒绝发布")
|
||||
for key in ("calendar", "exhibitions", "policies", "platforms", "activities"):
|
||||
if not payload.get(key):
|
||||
raise RuntimeError(f"核心栏目为空,拒绝发布:{key}")
|
||||
json.loads((data_dir / "textile-trends.json").read_text(encoding="utf-8"))
|
||||
return payload
|
||||
|
||||
|
||||
def snapshot_current(data_dir: Path, snapshot_dir: Path) -> None:
|
||||
if not any((data_dir / name).exists() for name in DATA_FILES):
|
||||
return
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
target = snapshot_dir / f"{stamp}.json.gz"
|
||||
content = {
|
||||
name: json.loads((data_dir / name).read_text(encoding="utf-8"))
|
||||
for name in DATA_FILES
|
||||
if (data_dir / name).exists()
|
||||
}
|
||||
with gzip.open(target, "wt", encoding="utf-8") as handle:
|
||||
json.dump(content, handle, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def prune_snapshots(snapshot_dir: Path) -> None:
|
||||
files = sorted(snapshot_dir.glob("*.json.gz"), key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
total = 0
|
||||
for index, path in enumerate(files):
|
||||
size = path.stat().st_size
|
||||
total += size
|
||||
if index >= MAX_SNAPSHOTS or total > MAX_SNAPSHOT_BYTES:
|
||||
path.unlink()
|
||||
|
||||
|
||||
def publish_data(staged: Path, target: Path) -> list[str]:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
changed = [name for name in DATA_FILES if (staged / name).exists() and digest(staged / name) != digest(target / name)]
|
||||
if changed:
|
||||
snapshot_dir = ROOT / "runtime" / "snapshots"
|
||||
snapshot_current(target, snapshot_dir)
|
||||
for name in changed:
|
||||
temp = target / f".{name}.tmp"
|
||||
shutil.copy2(staged / name, temp)
|
||||
os.replace(temp, target / name)
|
||||
prune_snapshots(snapshot_dir)
|
||||
return changed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="更新三笙AI情报局全部数据并构建站点")
|
||||
parser.add_argument("--site-url", default=os.environ.get("SANSHENG_SITE_URL", "http://127.0.0.1:8765"))
|
||||
parser.add_argument("--now", default="", help="验收日期 YYYY-MM-DD;生产运行留空")
|
||||
args = parser.parse_args()
|
||||
data_dir = ROOT / "data"
|
||||
runtime = ROOT / "runtime"
|
||||
runtime.mkdir(exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="update-", dir=runtime) as temp_name:
|
||||
staged = Path(temp_name) / "data"
|
||||
staged.mkdir()
|
||||
for name in DATA_FILES:
|
||||
if (data_dir / name).exists():
|
||||
shutil.copy2(data_dir / name, staged / name)
|
||||
run(sys.executable, "scripts/update_textile_trends.py", "--output-dir", str(staged))
|
||||
intelligence_command = [
|
||||
sys.executable, "scripts/update_textile_intelligence.py",
|
||||
"--output-dir", str(staged), "--strict",
|
||||
]
|
||||
if args.now:
|
||||
intelligence_command.extend(("--now", args.now))
|
||||
run(*intelligence_command)
|
||||
validate(staged)
|
||||
preview = Path(temp_name) / "dist"
|
||||
run(
|
||||
sys.executable, "scripts/build_site.py",
|
||||
"--data-dir", str(staged),
|
||||
"--output-dir", str(preview),
|
||||
"--site-url", args.site_url.rstrip("/"),
|
||||
)
|
||||
changed = publish_data(staged, data_dir)
|
||||
final_stage = ROOT / "runtime" / ".dist-next"
|
||||
if final_stage.exists():
|
||||
shutil.rmtree(final_stage)
|
||||
shutil.move(str(preview), final_stage)
|
||||
from build_site import replace_directory
|
||||
replace_directory(final_stage, ROOT / "dist")
|
||||
print(f"[更新完成] 数据变更 {len(changed)} 个文件;站点已重新构建")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[更新失败] 正式数据未被不完整抓取覆盖:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,914 @@
|
||||
#!/usr/bin/env python3
|
||||
"""抓取家纺情报权威源并生成前端数据。"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import asdict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
import requests
|
||||
if __package__:
|
||||
from .intelligence_calendar import (
|
||||
COUNTRY_ENGLISH_NAMES,
|
||||
aggregate_calendar_insights,
|
||||
calendar_opportunity as market_calendar_opportunity,
|
||||
calendar_scope,
|
||||
enrich_calendar_item as enrich_market_calendar_item,
|
||||
holiday_title as market_holiday_title,
|
||||
)
|
||||
from .intelligence_activities import build_activities, merge_previous_activities
|
||||
from .intelligence_exhibitions import build_exhibitions, translate_exhibitions
|
||||
from .intelligence_fetch import FetchResult, fetch_all_sources, fetch_json, iso_now, make_session, normalize_text
|
||||
from .intelligence_platforms import build_platforms
|
||||
from .intelligence_sources import SOURCES, SourceSpec, validate_registry
|
||||
from .intelligence_today import build_today, load_trends
|
||||
from .intelligence_translation import translate_calendar_titles
|
||||
from .intelligence_web_translation import translation_fields
|
||||
from .update_textile_trends import load_zh_cache, save_zh_cache, translate_to_zh_cn, validate_payload
|
||||
else:
|
||||
from intelligence_calendar import (
|
||||
COUNTRY_ENGLISH_NAMES,
|
||||
aggregate_calendar_insights,
|
||||
calendar_opportunity as market_calendar_opportunity,
|
||||
calendar_scope,
|
||||
enrich_calendar_item as enrich_market_calendar_item,
|
||||
holiday_title as market_holiday_title,
|
||||
)
|
||||
from intelligence_activities import build_activities, merge_previous_activities
|
||||
from intelligence_exhibitions import build_exhibitions, translate_exhibitions
|
||||
from intelligence_fetch import FetchResult, fetch_all_sources, fetch_json, iso_now, make_session, normalize_text
|
||||
from intelligence_platforms import build_platforms
|
||||
from intelligence_sources import SOURCES, SourceSpec, validate_registry
|
||||
from intelligence_today import build_today, load_trends
|
||||
from intelligence_translation import translate_calendar_titles
|
||||
from intelligence_web_translation import translation_fields
|
||||
from update_textile_trends import load_zh_cache, save_zh_cache, translate_to_zh_cn, validate_payload
|
||||
UTC = timezone.utc
|
||||
COUNTRY_CODES = (
|
||||
"CN", "US", "GB", "DE", "FR", "IT", "ES", "NL", "CA", "AU", "JP", "KR", "BR", "MX",
|
||||
"NZ", "SG", "ID", "VN", "PH", "ZA", "PL", "SE", "DK", "NO", "FI", "BE", "AT", "CH",
|
||||
"PT", "IE", "CZ", "TR", "RU",
|
||||
)
|
||||
COUNTRY_NAMES = {
|
||||
"CN": "中国", "US": "美国", "GB": "英国", "DE": "德国", "FR": "法国",
|
||||
"IT": "意大利", "ES": "西班牙", "NL": "荷兰", "CA": "加拿大", "AU": "澳大利亚",
|
||||
"JP": "日本", "KR": "韩国", "BR": "巴西", "MX": "墨西哥", "NZ": "新西兰",
|
||||
"SG": "新加坡", "ID": "印度尼西亚", "VN": "越南", "PH": "菲律宾", "ZA": "南非",
|
||||
"PL": "波兰", "SE": "瑞典", "DK": "丹麦", "NO": "挪威", "FI": "芬兰",
|
||||
"BE": "比利时", "AT": "奥地利", "CH": "瑞士", "PT": "葡萄牙", "IE": "爱尔兰",
|
||||
"CZ": "捷克", "TR": "土耳其", "AE": "阿联酋", "RU": "俄罗斯",
|
||||
}
|
||||
UK_AREA_NAMES = {
|
||||
"england-and-wales": "英格兰和威尔士",
|
||||
"scotland": "苏格兰",
|
||||
"northern-ireland": "北爱尔兰",
|
||||
}
|
||||
TEXTILE_RECALL_KEYWORDS = (
|
||||
"bedding", "blanket", "comforter", "duvet", "sheet", "pillow", "curtain", "rug",
|
||||
"carpet", "towel", "sleep sack", "mattress", "mattress cover", "crib liner",
|
||||
)
|
||||
POSTER_PALETTES = (
|
||||
("#0051d6", "#102a43", "#eef7ff"),
|
||||
("#2563eb", "#0b1324", "#eef5ff"),
|
||||
("#16a085", "#071b18", "#eafbf7"),
|
||||
("#5b6ee1", "#111a3c", "#f1f3ff"),
|
||||
("#2f8f83", "#0f302c", "#eefaf7"),
|
||||
)
|
||||
CULTURE_PALETTES = {
|
||||
"china": ("#0051d6", "#102a43", "#eef7ff"),
|
||||
"japan": ("#4776d0", "#172b4d", "#f2f6ff"),
|
||||
"korea": ("#315d9c", "#151b25", "#f1f6fb"),
|
||||
"north-america": ("#1677a6", "#12334a", "#edf8fc"),
|
||||
"british-isles": ("#365b9d", "#153561", "#f1f5fb"),
|
||||
"central-europe": ("#2f6b63", "#182722", "#edf8f5"),
|
||||
"southern-europe": ("#4a78a8", "#18384f", "#f1f7fb"),
|
||||
"nordic": ("#3d7f91", "#183d49", "#eef7f8"),
|
||||
"oceania": ("#168b83", "#164f4a", "#edf9f7"),
|
||||
"southeast-asia": ("#0f8f78", "#105d5b", "#ecfaf6"),
|
||||
"latin-america": ("#5568c9", "#273266", "#f1f2ff"),
|
||||
"southern-africa": ("#3f7c69", "#123d32", "#eff8f3"),
|
||||
"turkiye": ("#3e7194", "#17384a", "#eef6fa"),
|
||||
"islamic": ("#16836f", "#0e534a", "#edf9f5"),
|
||||
"sport": ("#1976a8", "#17324d", "#eef7fb"),
|
||||
"retail": ("#0051d6", "#102a43", "#eef7ff"),
|
||||
}
|
||||
def poster_for(key: str, section: str, culture_key: str = "") -> dict[str, str]:
|
||||
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()
|
||||
palette = CULTURE_PALETTES.get(culture_key) or POSTER_PALETTES[int(digest[:2], 16) % len(POSTER_PALETTES)]
|
||||
labels = {"calendar": "备货日历", "exhibition": "展会通", "policy": "政策预警", "standard": "趋势雷达"}
|
||||
return {"eyebrow": labels.get(section, "家纺跨境情报"), "accent": palette[0], "ink": palette[1], "paper": palette[2]}
|
||||
def make_id(prefix: str, *parts: str) -> str:
|
||||
raw = "||".join(parts).lower().encode("utf-8")
|
||||
return f"{prefix}-{hashlib.sha1(raw).hexdigest()[:14]}"
|
||||
def date_value(value: str) -> date:
|
||||
return date.fromisoformat(value[:10])
|
||||
def base_item(source: SourceSpec, result: FetchResult, section: str) -> dict[str, Any]:
|
||||
item_id = make_id(section, source.id, source.title_zh or result.title, source.url)
|
||||
return {
|
||||
"id": item_id,
|
||||
"section": section,
|
||||
"source_id": source.id,
|
||||
"source": source.name,
|
||||
"authority": source.authority,
|
||||
"region": source.region,
|
||||
"title": source.title_zh or result.title,
|
||||
"title_original": result.title,
|
||||
"summary": source.summary_zh or result.description,
|
||||
"summary_original": result.description,
|
||||
"url": result.final_url,
|
||||
"source_url": source.url,
|
||||
"image": result.image,
|
||||
"poster": poster_for(item_id, section),
|
||||
"verified_at": result.checked_at,
|
||||
"content_hash": result.content_hash,
|
||||
"change_status": "检测到更新" if result.changed else "持续监测",
|
||||
"confidence": "高" if source.authority in ("官方", "平台官方") else "中高",
|
||||
}
|
||||
def build_nager_events(now: date, horizon: date, source: SourceSpec, result: FetchResult) -> list[dict[str, Any]]:
|
||||
years = range(now.year, horizon.year + 1)
|
||||
requests_list = [(code, year) for code in COUNTRY_CODES for year in years]
|
||||
payloads: dict[tuple[str, int], list[dict[str, Any]]] = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = {
|
||||
executor.submit(fetch_json, f"https://date.nager.at/api/v3/PublicHolidays/{year}/{code}"): (code, year)
|
||||
for code, year in requests_list
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
code_year = futures[future]
|
||||
try:
|
||||
data = future.result()
|
||||
payloads[code_year] = data if isinstance(data, list) else []
|
||||
except Exception as exc:
|
||||
print(f"[日历警告] Nager {code_year[0]} {code_year[1]}: {exc}")
|
||||
items: list[dict[str, Any]] = []
|
||||
for (code, _year), rows in payloads.items():
|
||||
for row in rows:
|
||||
event_date = str(row.get("date", ""))
|
||||
if not event_date:
|
||||
continue
|
||||
day = date_value(event_date)
|
||||
if day < now or day > horizon:
|
||||
continue
|
||||
local_title = normalize_text(str(row.get("localName") or ""))
|
||||
english_title = normalize_text(str(row.get("name") or local_title or "公共假日"))
|
||||
title = market_holiday_title(local_title, english_title, code)
|
||||
subdivision_codes = [normalize_text(str(value)) for value in row.get("counties") or [] if value]
|
||||
applicable_areas, country_scope = calendar_scope(bool(row.get("global", True)), subdivision_codes)
|
||||
item = base_item(source, result, "calendar")
|
||||
country_slug = re.sub(r"[^a-z0-9]+", "-", COUNTRY_ENGLISH_NAMES.get(code, code).lower().replace("ü", "u")).strip("-")
|
||||
event_source_url = f"https://date.nager.at/PublicHoliday/{country_slug}/{day.year}"
|
||||
item.update({
|
||||
"id": make_id("calendar", source.id, code, event_date, title),
|
||||
"title": title,
|
||||
"title_original": local_title if local_title and local_title != title else english_title,
|
||||
"title_english": english_title,
|
||||
"summary": f"{COUNTRY_NAMES.get(code, code)}公共假日。{market_calendar_opportunity(f'{title} {english_title}', source.id)}",
|
||||
"region": COUNTRY_NAMES.get(code, code),
|
||||
"start_date": event_date,
|
||||
"end_date": event_date,
|
||||
"impact": "中",
|
||||
"opportunity": market_calendar_opportunity(f"{title} {english_title}", source.id),
|
||||
"market_area": code,
|
||||
"is_national_holiday": bool(row.get("global", True)),
|
||||
"subdivision_codes": subdivision_codes,
|
||||
"applicable_areas": applicable_areas,
|
||||
"country_scope": country_scope,
|
||||
"url": event_source_url,
|
||||
"source_url": event_source_url,
|
||||
"authority": "结构化公共假日数据",
|
||||
})
|
||||
items.append(enrich_market_calendar_item(item, now, code, poster_for))
|
||||
return items
|
||||
def build_govuk_events(now: date, horizon: date, source: SourceSpec, result: FetchResult) -> list[dict[str, Any]]:
|
||||
try:
|
||||
payload = json.loads(result.raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for area_key, area in payload.items():
|
||||
area_name = UK_AREA_NAMES.get(area_key, normalize_text(str(area.get("division") or area_key)))
|
||||
for row in area.get("events", []):
|
||||
event_date = str(row.get("date", ""))
|
||||
if not event_date:
|
||||
continue
|
||||
day = date_value(event_date)
|
||||
if day < now or day > horizon:
|
||||
continue
|
||||
title = normalize_text(str(row.get("title") or "Bank holiday"))
|
||||
item = base_item(source, result, "calendar")
|
||||
item.update({
|
||||
"id": make_id("calendar", source.id, area_key, event_date, title),
|
||||
"title": title,
|
||||
"title_english": title,
|
||||
"region": "英国",
|
||||
"start_date": event_date,
|
||||
"end_date": event_date,
|
||||
"impact": "中",
|
||||
"opportunity": market_calendar_opportunity(title, source.id),
|
||||
"summary": f"英国政府公布的 {area_name} 银行假日。{market_calendar_opportunity(title, source.id)}",
|
||||
"market_area": f"GB:{area_key}",
|
||||
"is_national_holiday": False,
|
||||
"applicable_areas": [area_name],
|
||||
"country_scope": f"仅适用于{area_name}",
|
||||
})
|
||||
items.append(enrich_market_calendar_item(item, now, "GB", poster_for))
|
||||
return items
|
||||
def build_aladhan_events(now: date, horizon: date, source: SourceSpec, result: FetchResult) -> list[dict[str, Any]]:
|
||||
"""从逐月公历转换结果中提取主要伊斯兰节庆节点。"""
|
||||
requests_list = [(month, year) for year in range(now.year, horizon.year + 1) for month in range(1, 13)]
|
||||
rows: list[dict[str, Any]] = []
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = {
|
||||
executor.submit(fetch_json, f"https://api.aladhan.com/v1/gToHCalendar/{month}/{year}"): (month, year)
|
||||
for month, year in requests_list
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
month_year = futures[future]
|
||||
try:
|
||||
payload = future.result()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("data"), list):
|
||||
rows.extend(payload["data"])
|
||||
except Exception as exc:
|
||||
print(f"[日历警告] AlAdhan {month_year[1]}-{month_year[0]:02d}: {exc}")
|
||||
markers = {
|
||||
(9, 1): ("斋月开始", "高", "提前 120 天准备中东礼赠床品、祈祷毯、餐桌布艺与夜间居家场景产品。"),
|
||||
(10, 1): ("开斋节 Eid al-Fitr", "高", "围绕家庭团聚与礼赠准备高品质床品套装、毛巾、桌布和节庆包装。"),
|
||||
(12, 10): ("宰牲节 Eid al-Adha", "高", "提前规划礼赠套装、客房床品、餐桌布艺和适配家庭聚会的耐用产品。"),
|
||||
(1, 1): ("伊斯兰新年", "中", "针对穆斯林市场准备焕新、礼赠和节庆主题软装内容。"),
|
||||
}
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
hijri = row.get("hijri") if isinstance(row, dict) else None
|
||||
gregorian = row.get("gregorian") if isinstance(row, dict) else None
|
||||
if not isinstance(hijri, dict) or not isinstance(gregorian, dict):
|
||||
continue
|
||||
month_info = hijri.get("month") if isinstance(hijri.get("month"), dict) else {}
|
||||
marker = (int(month_info.get("number", 0)), int(hijri.get("day", 0)))
|
||||
if marker not in markers:
|
||||
continue
|
||||
raw_date = str(gregorian.get("date", ""))
|
||||
try:
|
||||
day = datetime.strptime(raw_date, "%d-%m-%Y").date()
|
||||
except ValueError:
|
||||
continue
|
||||
if day < now or day > horizon:
|
||||
continue
|
||||
title, impact, opportunity = markers[marker]
|
||||
item = base_item(source, result, "calendar")
|
||||
item.update({
|
||||
"id": make_id("calendar", source.id, day.isoformat(), title),
|
||||
"title": title,
|
||||
"title_original": normalize_text(str(month_info.get("en") or title)),
|
||||
"region": "中东及穆斯林市场",
|
||||
"start_date": day.isoformat(),
|
||||
"end_date": day.isoformat(),
|
||||
"impact": impact,
|
||||
"opportunity": opportunity,
|
||||
"summary": f"基于 AlAdhan 公历与伊斯兰历转换结果。{opportunity}",
|
||||
"market_area": "MENA",
|
||||
})
|
||||
items.append(enrich_market_calendar_item(item, now, "", poster_for))
|
||||
return items
|
||||
def first_weekday_after(day: date, weekday: int) -> date:
|
||||
offset = (weekday - day.weekday()) % 7
|
||||
return day + timedelta(days=offset)
|
||||
def fourth_thursday(year: int, month: int) -> date:
|
||||
first = date(year, month, 1)
|
||||
first_thursday = first_weekday_after(first, 3)
|
||||
return first_thursday + timedelta(days=21)
|
||||
def fixed_calendar_items(now: date, horizon: date, results: dict[str, FetchResult]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
templates: list[tuple[str, date, date, str, str, str, str]] = []
|
||||
for year in range(now.year, horizon.year + 1):
|
||||
thanksgiving = fourth_thursday(year, 11)
|
||||
black_friday = thanksgiving + timedelta(days=1)
|
||||
cyber_monday = thanksgiving + timedelta(days=4)
|
||||
templates.extend((
|
||||
("calendar_nrf", date(year, 7, 15), date(year, 9, 15), "美国开学季与大学返校季", "高", "围绕 Twin XL 宿舍床品、浴巾、床垫保护套和收纳软品准备一站式套装。", "US"),
|
||||
("calendar_uk_school_terms", date(year, 9, 1), date(year, 9, 30), "英国秋季返校与学生住宿采购季", "高", "按目标地区校历核对开学时间,准备耐洗单人床品、床垫保护套、浴巾与宿舍收纳软品。", "GB"),
|
||||
("calendar_de_school_holidays", date(year, 8, 10), date(year, 9, 15), "德国夏末返校与学生住宿采购季", "高", "按目标州校历核对开学时间,准备耐用单人被套、床垫保护套、毛巾和中性色小空间软装。", "DE"),
|
||||
("calendar_nager", date(year, 8, 20), date(year, 9, 15), "法国新学年返校与学生住房采购季", "高", "按目标大区校历核对返校日,准备单人床品、被套、浴巾和小户型软装。", "FR"),
|
||||
("calendar_japan", date(year, 3, 1), date(year, 4, 15), "日本新学期与独居搬家采购季", "高", "围绕四月新学期、大学入学和独居搬家准备日式单人寝具、毛巾、窗帘与收纳软品。", "JP"),
|
||||
("calendar_nager", date(year, 8, 1), date(year, 9, 15), "加拿大返校与大学宿舍采购季", "高", "按目标省校历核对返校时间,准备保暖单人床品、床垫保护套、浴巾和宿舍收纳。", "CA"),
|
||||
("calendar_nager", date(year, 1, 5), date(year, 2, 15), "澳大利亚新学年与学生住宿采购季", "高", "按目标州校历核对第一学期开学日,准备透气床品、浴巾、防晒窗帘和学生公寓软装。", "AU"),
|
||||
("calendar_tmall", date(year, 11, 11), date(year, 11, 11), "双十一全球购物节", "高", "提前 120 至 180 天规划冬季床品、礼盒套装、智能温控与高客单家居软装。", "CN"),
|
||||
("calendar_nrf", black_friday, black_friday, "Black Friday 黑色星期五", "高", "提前 120 至 180 天完成北美海外仓入仓,主推套装、礼赠和强折扣款。", "US"),
|
||||
("calendar_nrf", cyber_monday, cyber_monday, "Cyber Monday 网络星期一", "高", "围绕线上转化准备轻小件、组合装、快递友好包装和补货库存。", "US"),
|
||||
("calendar_nrf", date(year, 11, 25), date(year, 12, 24), "北美冬季假日消费季", "高", "以圣诞配色、保暖床品、盖毯、餐桌布艺和礼赠包装形成系列化供给。", "US"),
|
||||
))
|
||||
for source_id, start, end, title, impact, opportunity, country_code in templates:
|
||||
if end < now or start > horizon:
|
||||
continue
|
||||
result = results[source_id]
|
||||
source = result.source
|
||||
item = base_item(source, result, "calendar")
|
||||
item.update({
|
||||
"id": make_id("calendar", source_id, start.isoformat(), title),
|
||||
"title": title,
|
||||
"start_date": start.isoformat(),
|
||||
"end_date": end.isoformat(),
|
||||
"impact": impact,
|
||||
"opportunity": opportunity,
|
||||
"summary": f"{source.summary_zh}{opportunity}",
|
||||
})
|
||||
school_review = {
|
||||
"US": ("National Retail Federation Back-to-School", "美国零售联合会专题数据", "https://nrf.com/research-insights/holiday-data-and-trends/back-to-school", "en"),
|
||||
"FR": ("法国教育部学校日历", "法国教育部官方校历(按大区复核)", "https://www.education.gouv.fr/calendrier-scolaire-100148", "fr"),
|
||||
"JP": ("日本文部科学省教育概览", "日本政府教育信息(具体日期按学校复核)", "https://www.mext.go.jp/en/policy/education/overview/index.htm", "en"),
|
||||
"CA": ("Ontario School Year Calendars", "加拿大安大略省政府校历(其他省另行复核)", "https://www.ontario.ca/page/school-year-calendars", "en"),
|
||||
"AU": ("Australian school term dates", "澳大利亚政府学期日期入口(按州复核)", "https://www.education.gov.au/about-department/resources/term-dates-across-australia", "en"),
|
||||
}
|
||||
translation_language = str(result.page_language or "")
|
||||
translation_text = f"{result.title} {result.description}"
|
||||
if country_code in school_review and ("开学" in title or "返校" in title or "新学期" in title or "新学年" in title):
|
||||
source_name, authority, review_url, translation_language = school_review[country_code]
|
||||
translation_text = source_name
|
||||
item.update({
|
||||
"url": review_url,
|
||||
"source_url": review_url,
|
||||
"source": source_name,
|
||||
"authority": authority,
|
||||
})
|
||||
item.update(translation_fields(
|
||||
str(item.get("source_url") or item.get("url") or ""),
|
||||
translation_language,
|
||||
translation_text,
|
||||
))
|
||||
item["market_area"] = country_code
|
||||
items.append(enrich_market_calendar_item(item, now, country_code, poster_for))
|
||||
for source_id in ("calendar_nfl", "calendar_la28"):
|
||||
result = results[source_id]
|
||||
source = result.source
|
||||
if not source.start_date or date_value(source.end_date) < now or date_value(source.start_date) > horizon:
|
||||
continue
|
||||
item = base_item(source, result, "calendar")
|
||||
item.update({
|
||||
"start_date": source.start_date,
|
||||
"end_date": source.end_date,
|
||||
"venue": source.venue,
|
||||
"impact": "高",
|
||||
"opportunity": source.opportunity,
|
||||
"market_area": "US",
|
||||
})
|
||||
items.append(enrich_market_calendar_item(item, now, "US", poster_for))
|
||||
return items
|
||||
def dedupe_calendar(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
uk_government_keys = {
|
||||
(str(item.get("start_date")), normalize_text(str(item.get("title_english") or item.get("title"))).lower())
|
||||
for item in items
|
||||
if item.get("source_id") == "calendar_uk"
|
||||
}
|
||||
seen: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
output: list[dict[str, Any]] = []
|
||||
ordered = sorted(
|
||||
items,
|
||||
key=lambda row: (
|
||||
row["start_date"],
|
||||
-int(row.get("priority_score") or 0),
|
||||
row["region"],
|
||||
row["title"],
|
||||
),
|
||||
)
|
||||
for item in ordered:
|
||||
english_key = normalize_text(str(item.get("title_english") or item.get("title"))).lower()
|
||||
if item.get("source_id") == "calendar_nager" and item.get("country_code") == "GB":
|
||||
if (str(item.get("start_date")), english_key) in uk_government_keys:
|
||||
continue
|
||||
market_area = normalize_text(str(item.get("market_area") or item.get("region"))).lower()
|
||||
key = (str(item.get("start_date")), market_area, english_key)
|
||||
if key in seen:
|
||||
existing = seen[key]
|
||||
codes = list(dict.fromkeys([*(existing.get("subdivision_codes") or []), *(item.get("subdivision_codes") or [])]))
|
||||
areas, scope = calendar_scope(False, codes)
|
||||
existing.update({"subdivision_codes": codes, "applicable_areas": areas, "country_scope": scope})
|
||||
continue
|
||||
seen[key] = item
|
||||
output.append(item)
|
||||
return output
|
||||
def build_calendar(now: date, horizon: date, results: dict[str, FetchResult]) -> tuple[list[dict[str, Any]], int]:
|
||||
items = build_nager_events(now, horizon, results["calendar_nager"].source, results["calendar_nager"])
|
||||
items.extend(build_govuk_events(now, horizon, results["calendar_uk"].source, results["calendar_uk"]))
|
||||
items.extend(build_aladhan_events(now, horizon, results["calendar_aladhan"].source, results["calendar_aladhan"]))
|
||||
items.extend(fixed_calendar_items(now, horizon, results))
|
||||
return dedupe_calendar(items), len(items)
|
||||
def cpsc_title_from_url(url: str, fallback: str) -> str:
|
||||
"""CPSC API 偶尔会串接相邻召回标题,官方详情 URL 的 slug 更稳定。"""
|
||||
path = unquote(urlparse(url).path)
|
||||
if "/Recalls/" not in path:
|
||||
return fallback
|
||||
slug = path.rstrip("/").split("/")[-1]
|
||||
if len(slug) < 12:
|
||||
return fallback
|
||||
return normalize_text(slug.replace("-", " "))
|
||||
def parse_cpsc_recalls(source: SourceSpec, result: FetchResult, now: date, limit: int = 24) -> list[dict[str, Any]]:
|
||||
try:
|
||||
payload = json.loads(result.raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
matches: list[dict[str, Any]] = []
|
||||
for row in payload if isinstance(payload, list) else []:
|
||||
products = row.get("Products") or []
|
||||
product_names = " ".join(str(product.get("Name", "")) for product in products if isinstance(product, dict))
|
||||
haystack = f"{row.get('Title', '')} {product_names}".lower()
|
||||
if not any(
|
||||
re.search(rf"(?<![a-z]){re.escape(keyword)}(?:s|es)?(?![a-z])", haystack)
|
||||
for keyword in TEXTILE_RECALL_KEYWORDS
|
||||
):
|
||||
continue
|
||||
recall_date = normalize_text(str(row.get("RecallDate") or ""))
|
||||
recall_url = normalize_text(str(row.get("URL") or result.final_url))
|
||||
api_title = normalize_text(str(row.get("Title") or product_names or "CPSC Recall"))
|
||||
title_original = cpsc_title_from_url(recall_url, api_title)
|
||||
description = normalize_text(str(row.get("Description") or source.summary_zh))[:600]
|
||||
combined = f"{title_original} {description}".lower()
|
||||
platforms = [name for token, name in (
|
||||
("amazon", "Amazon"), ("walmart", "Walmart"), ("target", "Target"),
|
||||
("temu", "Temu"), ("shein", "SHEIN"), ("ebay", "eBay"),
|
||||
) if token in combined]
|
||||
severe = any(token in combined for token in ("death", "fire", "suffocation", "entrapment", "strangulation"))
|
||||
try:
|
||||
published_day = date.fromisoformat(recall_date[:10])
|
||||
except ValueError:
|
||||
published_day = date.min
|
||||
change_status = "新发布召回" if published_day >= now - timedelta(days=45) else "历史有效召回"
|
||||
item_id = make_id("policy", source.id, str(row.get("RecallID", "")), title_original)
|
||||
item = {
|
||||
"id": item_id,
|
||||
"section": "policy",
|
||||
"source_id": source.id,
|
||||
"source": source.name,
|
||||
"authority": source.authority,
|
||||
"region": source.region,
|
||||
"title": f"美国消费品召回:{title_original}",
|
||||
"title_original": title_original,
|
||||
"summary": description,
|
||||
"summary_original": normalize_text(str(row.get("Description") or ""))[:600],
|
||||
"url": recall_url,
|
||||
"source_url": source.url,
|
||||
"image": "",
|
||||
"poster": poster_for(item_id, "policy"),
|
||||
"published_at": recall_date,
|
||||
"verified_at": result.checked_at,
|
||||
"content_hash": result.content_hash,
|
||||
"change_status": change_status,
|
||||
"confidence": "高",
|
||||
"risk_level": "高" if severe else "中",
|
||||
"policy_type": "产品安全与召回",
|
||||
"scope_type": "region",
|
||||
"affected_markets": ["美国"],
|
||||
"platforms": platforms,
|
||||
"affected_products": [name for name in product_names.split(" | ") if name] or [product_names],
|
||||
"effective_at": recall_date,
|
||||
"change_summary": f"CPSC 发布产品召回,涉及{product_names or '相关纺织与家居产品'}。",
|
||||
"consequence": "继续销售受召回商品可能导致平台下架、退款、监管处罚和人身安全责任。",
|
||||
"action": "立即核对在售与在途商品的型号、批次和风险描述,暂停受影响商品并按官方召回要求处理。",
|
||||
}
|
||||
item["action_priority"] = policy_action_priority(
|
||||
str(item["policy_type"]), str(item["change_status"]), str(item["risk_level"])
|
||||
)
|
||||
item.update(translation_fields(recall_url))
|
||||
matches.append(item)
|
||||
matches.sort(key=lambda row: row.get("published_at", ""), reverse=True)
|
||||
return matches[:limit]
|
||||
def policy_consequence(policy_type: str) -> str:
|
||||
if "平台" in policy_type:
|
||||
return "未按平台规则调整,可能导致商品限流、下架、扣款或店铺权限受限。"
|
||||
if "知识产权" in policy_type:
|
||||
return "未完成检索和留档,可能引发投诉、下架、冻结资金或跨境诉讼。"
|
||||
if any(token in policy_type for token in ("关税", "清关", "进口")):
|
||||
return "税费、申报或主体责任判断错误,会直接影响落地成本、清关时效和利润。"
|
||||
return "未及时核对规则,可能影响商品准入、标签、材料证明、物流或平台经营。"
|
||||
def policy_action_priority(policy_type: str, change_status: str, risk_level: str) -> int:
|
||||
score = 60 if risk_level == "高" else 35
|
||||
if change_status in ("新发布召回", "检测到近期更新"):
|
||||
score += 80
|
||||
if any(token in policy_type for token in ("关税", "清关", "进口", "平台")):
|
||||
score += 35
|
||||
elif "知识产权" in policy_type:
|
||||
score += 20
|
||||
elif policy_type == "产品安全与召回":
|
||||
score += 10
|
||||
return score
|
||||
def build_policies(results: dict[str, FetchResult], now: date) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for source in SOURCES:
|
||||
if source.section != "policy":
|
||||
continue
|
||||
result = results[source.id]
|
||||
if not result.ok:
|
||||
continue
|
||||
if source.extractor == "cpsc":
|
||||
recalls = parse_cpsc_recalls(source, result, now)
|
||||
if recalls:
|
||||
items.extend(recalls)
|
||||
continue
|
||||
item = base_item(source, result, "policy")
|
||||
policy_type, markets, platforms, action = policy_profile(source)
|
||||
try:
|
||||
published_day = date.fromisoformat(str(result.published_at or "")[:10])
|
||||
except ValueError:
|
||||
published_day = date.min
|
||||
verified_change = result.changed and published_day >= now - timedelta(days=45)
|
||||
item.update({
|
||||
"published_at": result.published_at,
|
||||
"risk_level": source.risk_level,
|
||||
"policy_type": policy_type,
|
||||
"scope_type": "platform" if platforms else ("ip" if "知识产权" in policy_type else "region"),
|
||||
"affected_markets": markets,
|
||||
"platforms": platforms,
|
||||
"change_status": "检测到近期更新" if verified_change else "官方规则持续监测",
|
||||
"change_summary": source.summary_zh or result.description,
|
||||
"consequence": policy_consequence(policy_type),
|
||||
"action": action,
|
||||
})
|
||||
item["action_priority"] = policy_action_priority(
|
||||
policy_type, str(item["change_status"]), str(item.get("risk_level") or "")
|
||||
)
|
||||
item.update(translation_fields(
|
||||
str(item.get("url") or source.url),
|
||||
str(result.page_language or ""),
|
||||
f"{result.title} {result.description}",
|
||||
))
|
||||
items.append(item)
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda row: (
|
||||
-int(row.get("action_priority") or 0),
|
||||
str(row.get("published_at") or ""),
|
||||
row["source"],
|
||||
),
|
||||
)
|
||||
def refine_textile_translation(value: str) -> str:
|
||||
replacements = {
|
||||
"游戏庭院": "游戏围栏",
|
||||
"游戏床床垫": "游戏围栏垫",
|
||||
"夏尔巴人的毯子和毯子": "羊羔绒盖毯",
|
||||
"电动毯子和毯子": "电热盖毯和毛毯",
|
||||
"夏尔巴人、加热的毯子和毯子": "羊羔绒电热盖毯和毛毯",
|
||||
"休闲毯和毯子": "盖毯和毛毯",
|
||||
"亚马逊": "Amazon",
|
||||
"在Amazon": "在 Amazon",
|
||||
"漂亮的生活": "Pretty-Life",
|
||||
}
|
||||
output = value
|
||||
for source, target in replacements.items():
|
||||
output = output.replace(source, target)
|
||||
return output
|
||||
def translate_policy_titles(policies: list[dict[str, Any]], output_dir: Path, max_new: int = 72) -> None:
|
||||
cache = load_zh_cache(output_dir)
|
||||
session = make_session()
|
||||
translated_count = 0
|
||||
cache_changed = False
|
||||
for item in policies:
|
||||
if item.get("source_id") != "policy_cpsc":
|
||||
continue
|
||||
original = normalize_text(str(item.get("title_original") or ""))
|
||||
if not original:
|
||||
continue
|
||||
translated = cache.get(original)
|
||||
if not translated and translated_count < max_new:
|
||||
translated = translate_to_zh_cn(session, original)
|
||||
if translated:
|
||||
cache[original] = translated
|
||||
translated_count += 1
|
||||
cache_changed = True
|
||||
if translated:
|
||||
item["title"] = f"美国消费品召回:{refine_textile_translation(translated)}"
|
||||
item["translation_status"] = "已翻译并保留原文"
|
||||
else:
|
||||
item["translation_status"] = "保留官方原文"
|
||||
summary_original = normalize_text(str(item.get("summary_original") or ""))
|
||||
if not summary_original:
|
||||
continue
|
||||
summary_translated = cache.get(summary_original)
|
||||
if not summary_translated and translated_count < max_new:
|
||||
summary_translated = translate_to_zh_cn(session, summary_original)
|
||||
if summary_translated:
|
||||
cache[summary_original] = summary_translated
|
||||
translated_count += 1
|
||||
cache_changed = True
|
||||
if summary_translated:
|
||||
item["summary"] = refine_textile_translation(summary_translated)
|
||||
if cache_changed:
|
||||
save_zh_cache(output_dir, cache)
|
||||
def translate_activity_titles(activities: list[dict[str, Any]], output_dir: Path, max_new: int = 72) -> None:
|
||||
cache = load_zh_cache(output_dir)
|
||||
session = make_session()
|
||||
translated_count = 0
|
||||
cache_changed = False
|
||||
for item in activities:
|
||||
original = normalize_text(str(item.get("title") or ""))
|
||||
if not original or re.search(r"[\u4e00-\u9fff]", original):
|
||||
continue
|
||||
translated = cache.get(original)
|
||||
if not translated and translated_count < max_new:
|
||||
translated = translate_to_zh_cn(session, original)
|
||||
if translated:
|
||||
cache[original] = translated
|
||||
translated_count += 1
|
||||
cache_changed = True
|
||||
if translated:
|
||||
item["title_original"] = original
|
||||
item["title"] = refine_textile_translation(translated)
|
||||
item["translation_status"] = "已翻译并保留原文"
|
||||
else:
|
||||
item["translation_status"] = "保留官方原文"
|
||||
if cache_changed:
|
||||
save_zh_cache(output_dir, cache)
|
||||
def policy_profile(source: SourceSpec) -> tuple[str, list[str], list[str], str]:
|
||||
if source.policy_type:
|
||||
return source.policy_type, list(source.markets or (source.region,)), list(source.platforms), source.action_zh
|
||||
profiles = {
|
||||
"policy_ustr": ("关税与贸易救济", ["美国", "中国出口"], [], "核对家纺税号与附加关税清单,重算落地成本、报价和供应链方案。"),
|
||||
"policy_cbp": ("进口与清关", ["美国"], [], "核对申报、原产地、小额包裹、强迫劳动和承运商资料要求。"),
|
||||
"policy_eu_customs": ("关税与清关", ["欧盟"], [], "按欧盟目的国复核 IOSS、低价值包裹、税费、商品编码和进口主体责任。"),
|
||||
"policy_eu_textiles": ("纺织可持续合规", ["欧盟"], [], "评估生态设计、数字产品护照、生产者责任和绿色声明对产品资料的影响。"),
|
||||
"policy_echa_chem": ("化学品与材料合规", ["欧盟"], [], "核查染料、涂层、阻燃和功能整理所涉物质的限制、授权与供应链文件。"),
|
||||
"policy_ftc_textile": ("纺织标签", ["美国"], [], "逐项核对纤维成分、原产地、责任主体和广告披露,并留存标签审核版本。"),
|
||||
"policy_uk_tariff": ("关税与商品编码", ["英国"], [], "查询商品编码、关税、增值税和进出口限制,确认进口主体及税费展示方式。"),
|
||||
"policy_mofcom": ("外贸与跨境电商", ["中国"], [], "确认出口、跨境电商、市场准入和管制政策是否影响订单、结算与物流。"),
|
||||
"policy_wipo_branddb": ("知识产权检索", ["全球"], [], "在命名、图案和包装定稿前检索近似商标与图形要素,并保存检索记录。"),
|
||||
"policy_euipo_search": ("知识产权检索", ["欧盟"], [], "在欧盟上新前检索商标与外观设计,核对权利状态、类别和权利人。"),
|
||||
}
|
||||
return profiles.get(
|
||||
source.id,
|
||||
("综合合规", [source.region], [], "打开官方来源核查适用商品、地区、生效日期与申报要求,并由业务负责人确认执行。"),
|
||||
)
|
||||
def build_standards(results: dict[str, FetchResult]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for source in SOURCES:
|
||||
if source.section != "standard":
|
||||
continue
|
||||
result = results[source.id]
|
||||
if not result.ok:
|
||||
continue
|
||||
item = base_item(source, result, "standard")
|
||||
profile = standard_profile(source.id)
|
||||
item.update({
|
||||
"published_at": result.published_at,
|
||||
"signal": "权威源已验证",
|
||||
**profile,
|
||||
})
|
||||
items.append(item)
|
||||
return items
|
||||
def standard_profile(source_id: str) -> dict[str, Any]:
|
||||
profiles = {
|
||||
"standard_pantone": {
|
||||
"trend_dimension": "色彩与设计",
|
||||
"application": "将 Cloud Dancer 作为中性色方向,与目的国热销色、材质肌理和季节需求交叉验证后,再转成床品、窗帘和软装打样方案。",
|
||||
"product_targets": ["床品配色", "窗帘色组", "软装组合", "包装视觉"],
|
||||
"task_type": "生成配色打样单",
|
||||
"task_output": "A4 HTML / 浏览器打印 PDF",
|
||||
"decision_note": "年度代表色是设计信号,不等于所有市场都会热卖;先用小样和平台数据验证。",
|
||||
"official_color": {
|
||||
"name_zh": "云舞白(本站译名)",
|
||||
"name_en": "Cloud Dancer",
|
||||
"pantone_code": "PANTONE 11-4201",
|
||||
"pantone_system": "具体色卡后缀以 Pantone 实体色卡和授权数据为准",
|
||||
"hex": "#F2F0E8",
|
||||
"rgb": "242, 240, 232",
|
||||
"value_notice": "Pantone 名称与编号来自官方年度色页面;HEX 与 RGB 是本站屏幕近似值,不代替实体色卡、Lab 值或大货对色。",
|
||||
},
|
||||
"swatches": [
|
||||
{"name_zh": "云舞白(本站译名)", "name_en": "Cloud Dancer", "hex": "#F2F0E8", "rgb": "242, 240, 232", "role": "60% 主色", "source_type": "官方英文名与编号 / 本站屏幕近似值"},
|
||||
{"name_zh": "植物灰绿", "name_en": "Botanical Sage", "hex": "#738075", "rgb": "115, 128, 117", "role": "25% 辅色", "source_type": "本站设计建议"},
|
||||
{"name_zh": "深海蓝", "name_en": "Deep Ocean Blue", "hex": "#25465A", "rgb": "37, 70, 90", "role": "10% 结构色", "source_type": "本站设计建议"},
|
||||
{"name_zh": "珊瑚红", "name_en": "Coral Red", "hex": "#C85D4B", "rgb": "200, 93, 75", "role": "5% 点缀", "source_type": "本站设计建议"},
|
||||
],
|
||||
"sample_templates": [
|
||||
{"product": "床品四件套", "material": "60支精梳棉或棉莱赛尔", "weight": "按面料规格核算,不以成品克重代替支数", "craft": "活性印染 / 纯色拼接", "pattern_scale": "主色大面积,结构色用于滚边或细线条", "market": "欧美中高客单与酒店风市场", "platform": "Amazon / 独立站", "price_band": "中高价带"},
|
||||
{"product": "窗帘与靠垫", "material": "棉麻混纺或遮光复合面料", "weight": "窗帘建议先按遮光率与垂感选样", "craft": "提花 / 包边 / 同色异材质", "pattern_scale": "辅色用于中尺度植物纹,点缀色不超过 5%", "market": "欧洲与北美现代自然风", "platform": "Etsy / Amazon / Wayfair", "price_band": "中价至中高价"},
|
||||
{"product": "盖毯与软装", "material": "棉、再生纤维或羊羔绒混合方案", "weight": "按季节分别打轻量与保暖样", "craft": "绗缝 / 流苏 / 纹理织造", "pattern_scale": "保持低饱和,点缀色用于小面积标签和包边", "market": "秋冬居家与礼赠场景", "platform": "Walmart / Amazon / TikTok Shop", "price_band": "大众至中价"},
|
||||
],
|
||||
"brief_items": ["目标国家与季节", "主色/辅色/点缀色比例", "适用材质与克重", "床品/窗帘/包装三套效果", "平台主图 A/B 测试计划"],
|
||||
"measurement_note": "HEX 仅为屏幕打样参考,不替代 Pantone 实物色卡、供应商 Lab 值或大货对色。",
|
||||
},
|
||||
"standard_textile_exchange": {
|
||||
"trend_dimension": "材料与可持续",
|
||||
"application": "筛选再生纤维、优选材料和可追溯供应链方案,要求供应商同时提供材料声明、认证范围、批次证据与环境主张依据。",
|
||||
"product_targets": ["纱线与面料", "填充材料", "再生纤维", "供应链证据"],
|
||||
"task_type": "下载材料证据清单",
|
||||
"decision_note": "可持续卖点必须能追溯到具体工厂、批次、产品范围和声明依据。",
|
||||
"brief_items": ["材料名称与供应商", "认证机构与证书编号", "证书覆盖工厂/产品/有效期", "批次与交易凭证", "再生成分比例", "宣传文案证据来源"],
|
||||
},
|
||||
"standard_oeko_tex": {
|
||||
"trend_dimension": "认证与限值",
|
||||
"application": "按目标市场和产品接触等级核对面料、染整、辅料和成品的有害物质限值,确认认证证书覆盖具体工厂、产品类别和有效期。",
|
||||
"product_targets": ["婴童床品", "贴身纺织", "染整助剂", "辅料与成品"],
|
||||
"task_type": "下载认证核对单",
|
||||
"decision_note": "证书存在不代表你的 SKU 自动被覆盖,必须核对证书主体、范围、有效期和产品等级。",
|
||||
"brief_items": ["证书编号与查询结果", "持证主体与实际工厂", "产品级别与接触类型", "覆盖材料/颜色/辅料", "有效期", "大货批次与检测报告关联"],
|
||||
},
|
||||
"standard_aatcc": {
|
||||
"trend_dimension": "性能与测试",
|
||||
"application": "把色牢度、耐磨、起球、尺寸稳定、吸湿快干与凉感等指标转成采购技术规格,并在大货前完成同批次实验室验证。",
|
||||
"product_targets": ["接触凉感", "色牢度", "耐磨起球", "尺寸稳定"],
|
||||
"task_type": "下载性能测试单",
|
||||
"decision_note": "Q-MAX 等指标只有和测试方法、调湿条件、单位及合格阈值一起写明才有采购意义。",
|
||||
"brief_items": ["产品与使用场景", "测试标准/方法版本", "试样调湿与测试条件", "单位与目标值", "洗前/洗后要求", "抽样规则", "第三方实验室与报告编号"],
|
||||
},
|
||||
}
|
||||
return profiles.get(source_id, {
|
||||
"trend_dimension": "综合趋势",
|
||||
"application": "与家纺图文趋势交叉验证后用于产品决策。",
|
||||
"product_targets": ["床品", "窗帘", "软装"],
|
||||
"task_type": "生成产品任务",
|
||||
"decision_note": "先核对来源与适用范围,再用于打样或采购。",
|
||||
"brief_items": ["目标市场", "适用品类", "证据来源", "打样或采购动作"],
|
||||
})
|
||||
def public_source_status(result: FetchResult) -> dict[str, Any]:
|
||||
payload = asdict(result.source)
|
||||
payload.update({
|
||||
"ok": result.ok,
|
||||
"status_code": result.status_code,
|
||||
"final_url": result.final_url,
|
||||
"content_type": result.content_type,
|
||||
"elapsed_ms": result.elapsed_ms,
|
||||
"size_bytes": result.size_bytes,
|
||||
"content_hash": result.content_hash,
|
||||
"hash_version": 3,
|
||||
"changed": result.changed,
|
||||
"checked_at": result.checked_at,
|
||||
"page_title": result.title,
|
||||
"page_description": result.description,
|
||||
"page_language": result.page_language,
|
||||
"image": result.image,
|
||||
"evidence": result.evidence or [],
|
||||
"error": result.error,
|
||||
})
|
||||
return payload
|
||||
def write_json(path: Path, payload: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = path.with_suffix(path.suffix + ".tmp")
|
||||
temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
|
||||
temp.replace(path)
|
||||
def load_previous_hashes(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return {
|
||||
str(row.get("id")): str(row.get("content_hash"))
|
||||
for row in payload.get("sources", [])
|
||||
if isinstance(row, dict) and row.get("id") and row.get("content_hash") and row.get("hash_version") == 3
|
||||
}
|
||||
def existing_trend_source_count(output_dir: Path) -> int:
|
||||
path = output_dir / "textile-trends.json"
|
||||
if not path.exists():
|
||||
return 0
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return max(0, int(payload.get("source_count") or 0))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return 0
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="更新家纺跨境情报数据")
|
||||
parser.add_argument("--output-dir", default="data", help="JSON 输出目录")
|
||||
parser.add_argument("--now", default="", help="验收基准日期 YYYY-MM-DD,默认使用北京时间日期")
|
||||
parser.add_argument("--horizon", default="2028-12-31", help="日历最远展示日期")
|
||||
parser.add_argument("--max-workers", type=int, default=8, help="源站并发数")
|
||||
parser.add_argument("--strict", action="store_true", help="任一权威源失败即退出,不覆盖正式数据")
|
||||
args = parser.parse_args()
|
||||
validate_registry()
|
||||
now = date.fromisoformat(args.now) if args.now else datetime.now(tz=ZoneInfo("Asia/Shanghai")).date()
|
||||
horizon = date.fromisoformat(args.horizon)
|
||||
if horizon < now:
|
||||
raise ValueError("horizon 不能早于 now")
|
||||
output_dir = Path(args.output_dir)
|
||||
previous_payload: dict[str, Any] = {}
|
||||
previous_path = output_dir / "textile-intelligence.json"
|
||||
try:
|
||||
previous_payload = json.loads(previous_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
previous_payload = {}
|
||||
trend_source_count = existing_trend_source_count(output_dir)
|
||||
previous_hashes = load_previous_hashes(output_dir / "textile-intelligence-source-status.json")
|
||||
results = fetch_all_sources(max(1, min(args.max_workers, 12)))
|
||||
for source_id, result in results.items():
|
||||
previous_hash = previous_hashes.get(source_id)
|
||||
result.changed = bool(previous_hash and result.content_hash and previous_hash != result.content_hash)
|
||||
success_count = sum(1 for result in results.values() if result.ok)
|
||||
failed = [result for result in results.values() if not result.ok]
|
||||
print(f"权威源验证:{success_count}/{len(SOURCES)} 通过")
|
||||
status_payload = {
|
||||
"version": 4,
|
||||
"generated_at": iso_now(),
|
||||
"target_sources": len(SOURCES),
|
||||
"success_sources": success_count,
|
||||
"failed_sources": len(failed),
|
||||
"existing_trend_sources": trend_source_count,
|
||||
"total_source_baseline": trend_source_count + success_count,
|
||||
"sources": [public_source_status(results[source.id]) for source in SOURCES],
|
||||
}
|
||||
write_json(output_dir / "textile-intelligence-source-status.json", status_payload)
|
||||
if failed and args.strict:
|
||||
for result in failed:
|
||||
print(f"[严格模式失败] {result.source.name}: {result.error}")
|
||||
return 1
|
||||
valid_results = {source_id: result for source_id, result in results.items() if result.ok}
|
||||
required_calendar = (
|
||||
"calendar_nager", "calendar_uk", "calendar_uk_school_terms", "calendar_de_school_holidays",
|
||||
"calendar_aladhan", "calendar_nrf",
|
||||
"calendar_tmall", "calendar_nfl", "calendar_la28",
|
||||
)
|
||||
if any(source_id not in valid_results for source_id in required_calendar):
|
||||
print("关键日历源未全部通过,停止生成正式数据")
|
||||
return 1
|
||||
activities, activity_sources = build_activities(now, iso_now(), make_session())
|
||||
activities = merge_previous_activities(activities, list(previous_payload.get("activities") or []), now)
|
||||
translate_activity_titles(activities, output_dir)
|
||||
activity_success = sum(bool(source.get("ok")) for source in activity_sources)
|
||||
status_payload.update({
|
||||
"activity_target_sources": len(activity_sources),
|
||||
"activity_success_sources": activity_success,
|
||||
"activity_failed_sources": len(activity_sources) - activity_success,
|
||||
"activity_sources": activity_sources,
|
||||
})
|
||||
write_json(output_dir / "textile-intelligence-source-status.json", status_payload)
|
||||
if args.strict and activity_success != len(activity_sources):
|
||||
for source in activity_sources:
|
||||
if not source.get("ok"):
|
||||
print(f"[活动源严格模式失败] {source['name']}: {source.get('error')}")
|
||||
return 1
|
||||
calendar, raw_calendar_count = build_calendar(now, horizon, valid_results)
|
||||
translate_calendar_titles(calendar, output_dir)
|
||||
calendar_insights = aggregate_calendar_insights(calendar, now, poster_for)
|
||||
exhibitions = build_exhibitions(now, horizon, SOURCES, valid_results, base_item, make_id, poster_for)
|
||||
translate_exhibitions(exhibitions, output_dir)
|
||||
policies = build_policies(valid_results, now)
|
||||
translate_policy_titles(policies, output_dir)
|
||||
standards = build_standards(valid_results)
|
||||
for item in standards:
|
||||
source_result = valid_results.get(str(item.get("source_id") or ""))
|
||||
if source_result:
|
||||
item.update(translation_fields(
|
||||
str(item.get("url") or item.get("source_url") or ""),
|
||||
str(source_result.page_language or ""),
|
||||
f"{source_result.title} {source_result.description}",
|
||||
))
|
||||
platforms = build_platforms(iso_now(), make_session())
|
||||
trends = load_trends(output_dir / "textile-trends.json")
|
||||
today = build_today(now, calendar_insights, exhibitions, policies, standards, activities, trends)
|
||||
payload = {
|
||||
"version": 2,
|
||||
"generated_at": iso_now(),
|
||||
"as_of_date": now.isoformat(),
|
||||
"horizon_date": horizon.isoformat(),
|
||||
"source_target": len(SOURCES),
|
||||
"source_success": success_count,
|
||||
"existing_trend_sources": trend_source_count,
|
||||
"total_source_baseline": trend_source_count + success_count,
|
||||
"summary": {
|
||||
"calendar_events": len(calendar),
|
||||
"calendar_insights": len(calendar_insights),
|
||||
"calendar_featured": sum(
|
||||
item.get("business_relevance") in ("high", "medium")
|
||||
and item.get("affected_countries")
|
||||
and item.get("platforms")
|
||||
and item.get("product_suggestions")
|
||||
and item.get("stocking_start")
|
||||
and date_value(item.get("stocking_end") or item["start_date"]) >= now
|
||||
for item in calendar_insights
|
||||
),
|
||||
"calendar_raw_events": raw_calendar_count,
|
||||
"calendar_duplicates_removed": raw_calendar_count - len(calendar),
|
||||
"calendar_markets": len({item.get("country_code") or item.get("market_group") for item in calendar}),
|
||||
"calendar_years": sorted({date_value(item["start_date"]).year for item in calendar}),
|
||||
"calendar_event_types": len({item.get("event_type") for item in calendar}),
|
||||
"exhibitions": len(exhibitions),
|
||||
"policies": len(policies),
|
||||
"standards": len(standards),
|
||||
"platforms": len(platforms),
|
||||
"activities": len(activities),
|
||||
"activity_sources": activity_success,
|
||||
"today_items": len(today),
|
||||
},
|
||||
"today": today,
|
||||
"calendar": calendar,
|
||||
"calendar_insights": calendar_insights,
|
||||
"exhibitions": exhibitions,
|
||||
"policies": policies,
|
||||
"standards": standards,
|
||||
"platforms": platforms,
|
||||
"activities": activities,
|
||||
"activity_sources": activity_sources,
|
||||
"sources": [public_source_status(results[source.id]) for source in SOURCES if results[source.id].ok],
|
||||
}
|
||||
validate_payload(payload)
|
||||
write_json(output_dir / "textile-intelligence.json", payload)
|
||||
print(
|
||||
"情报数据生成完成:"
|
||||
f"日历原始节点 {len(calendar)} / 聚合节点 {len(calendar_insights)} / 精准机会 {payload['summary']['calendar_featured']} / "
|
||||
f"展会 {len(exhibitions)} / 活动 {len(activities)} / 政策 {len(policies)} / 标准 {len(standards)}"
|
||||
)
|
||||
return 0
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,924 @@
|
||||
#!/usr/bin/env python3
|
||||
"""家纺流行趋势数据采集脚本 — 聚合多个行业数据源,生成 textile-trends.json。
|
||||
|
||||
数据源清单:
|
||||
1. TrendHunter — hipfashion / furniture
|
||||
2. 免费趋势博客 RSS — Dezeen / Design Milk / Fibre2Fashion 等
|
||||
3. 时装趋势 RSS — Hypebeast / GQ Hong Kong / Citta Bella
|
||||
4. The Met / V&A 开放博物馆图像
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if __package__:
|
||||
from .textile_sources import CURATED_TREND_PAGES, FREE_TREND_FEEDS, is_fashion_reference
|
||||
from .intelligence_web_translation import translation_fields
|
||||
else:
|
||||
from textile_sources import CURATED_TREND_PAGES, FREE_TREND_FEEDS, is_fashion_reference
|
||||
from intelligence_web_translation import translation_fields
|
||||
|
||||
try:
|
||||
import feedparser # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError:
|
||||
feedparser = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from dateutil import parser as dtparser # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError:
|
||||
dtparser = None # type: ignore[assignment]
|
||||
|
||||
# ────────────── 常量 ──────────────
|
||||
|
||||
UTC = timezone.utc
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# 指定的 Dezeen Showroom HTML 爬取源
|
||||
DEZEEN_SHOWROOM_URLS: list[dict[str, str]] = [
|
||||
{"name": "Dezeen Showroom - Fabrics", "url": "https://www.dezeen.com/showroom/textiles-soft-furnishings/fabrics/"},
|
||||
{"name": "Dezeen Showroom - Textiles & Furnishings", "url": "https://www.dezeen.com/showroom/textiles-soft-furnishings/"},
|
||||
]
|
||||
|
||||
BLOCKED_PAID_TREND_MARKERS = ("91jiafang", "91家纺", "pop-fashion", "pop趋势网", "pop家居")
|
||||
|
||||
|
||||
# ────────────── 数据结构 ──────────────
|
||||
|
||||
@dataclass
|
||||
class TrendItem:
|
||||
"""一条趋势数据。"""
|
||||
source: str # 数据源名称
|
||||
category: str # 分类名
|
||||
category_key: str # 分类键
|
||||
title: str
|
||||
url: str
|
||||
thumbnail: str = ""
|
||||
image_original: str = ""
|
||||
summary: str = ""
|
||||
published_at: str | None = None
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ────────────── 翻译逻辑 ──────────────
|
||||
|
||||
def translate_to_zh_cn(session: requests.Session, text: str) -> str | None:
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
import os
|
||||
proxies = {}
|
||||
proxy_url = os.environ.get("HTTP_PROXY") or os.environ.get("HTTPS_PROXY") or os.environ.get("http_proxy") or os.environ.get("https_proxy")
|
||||
if proxy_url:
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
|
||||
r = session.get(
|
||||
"https://translate.googleapis.com/translate_a/single",
|
||||
params={
|
||||
"client": "gtx",
|
||||
"sl": "auto",
|
||||
"tl": "zh-CN",
|
||||
"dt": "t",
|
||||
"q": s,
|
||||
},
|
||||
proxies=proxies if proxies else None,
|
||||
timeout=12,
|
||||
)
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return None
|
||||
segs = payload[0]
|
||||
if not isinstance(segs, list):
|
||||
return None
|
||||
translated = "".join(str(seg[0]) for seg in segs if isinstance(seg, list) and seg and seg[0])
|
||||
translated = translated.strip()
|
||||
if translated and translated != s:
|
||||
return translated
|
||||
except Exception as exc:
|
||||
print(f" [翻译警告] '{s[:25]}...' 汉化失败: {exc}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def load_zh_cache(output_dir: Path) -> dict[str, str]:
|
||||
path = output_dir / "textile-zh-cache.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return {str(k): str(v) for k, v in data.items() if str(k).strip() and str(v).strip()}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_zh_cache(output_dir: Path, cache: dict[str, str]) -> None:
|
||||
path = output_dir / "textile-zh-cache.json"
|
||||
try:
|
||||
path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ────────────── 工具函数 ──────────────
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(tz=UTC)
|
||||
|
||||
|
||||
def iso(dt: datetime | None) -> str | None:
|
||||
if not dt:
|
||||
return None
|
||||
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def make_trend_id(source: str, title: str, url: str) -> str:
|
||||
"""生成趋势条目唯一 ID。"""
|
||||
key = "||".join([source.strip().lower(), title.strip().lower(), url.strip().lower()])
|
||||
return hashlib.sha1(key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
VAM_GENERIC_TITLE_ZH = {
|
||||
"hanging": "壁挂织物",
|
||||
"pattern": "纹样图稿",
|
||||
"embroidery sample": "刺绣样本",
|
||||
"textile": "纺织藏品",
|
||||
"embroidery": "刺绣作品",
|
||||
"machine embroidered textile": "机绣织物",
|
||||
}
|
||||
|
||||
|
||||
def curate_vam_title(title: str) -> str:
|
||||
match = re.fullmatch(r"\s*(.+?)\s+\[(O\d+)\]\s*", title, re.IGNORECASE)
|
||||
if not match:
|
||||
return ""
|
||||
localized = VAM_GENERIC_TITLE_ZH.get(match.group(1).strip().casefold(), "")
|
||||
return f"{localized}(V&A {match.group(2).upper()})" if localized else ""
|
||||
|
||||
|
||||
def parse_date_str(value: Any) -> str | None:
|
||||
"""尝试解析日期字符串为 ISO 格式。"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
|
||||
# 尝试 dateutil 解析
|
||||
if dtparser is not None:
|
||||
try:
|
||||
dt = dtparser.parse(s, tzinfos={"UT": 0, "UTC": 0, "GMT": 0})
|
||||
if not dt.tzinfo:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return iso(dt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试常见中文日期格式: 2026-06-01 / 2026.06.01 / 2026年06月01日
|
||||
m = re.search(r"(\d{4})[\-\.年/](\d{1,2})[\-\.月/](\d{1,2})", s)
|
||||
if m:
|
||||
try:
|
||||
dt = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), tzinfo=UTC)
|
||||
return iso(dt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_session() -> requests.Session:
|
||||
"""创建带重试策略的 HTTP 会话。"""
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=3,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=2.0,
|
||||
backoff_max=30,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=frozenset(["GET", "POST"]),
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
session.headers.update({
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
})
|
||||
return session
|
||||
|
||||
|
||||
# ────────────── 免费流行趋势 RSS 聚合 ──────────────
|
||||
|
||||
def fetch_free_trend_rss(session: requests.Session, now: datetime) -> list[TrendItem]:
|
||||
"""通过 RSS 聚合各种国际免费的 Textiles / Fashion / Home Design 精选趋势渠道。"""
|
||||
items: list[TrendItem] = []
|
||||
if feedparser is None:
|
||||
print(" [免费趋势RSS] feedparser 未安装,跳过 RSS 抓取")
|
||||
return items
|
||||
|
||||
for feed_info in FREE_TREND_FEEDS:
|
||||
feed_name = feed_info["name"]
|
||||
feed_url = feed_info["url"]
|
||||
category_name = str(feed_info.get("category") or "趋势博客")
|
||||
category_key = str(feed_info.get("category_key") or "trend_blogs")
|
||||
|
||||
try:
|
||||
r = session.get(feed_url, timeout=20)
|
||||
r.raise_for_status()
|
||||
parsed = feedparser.parse(r.content)
|
||||
|
||||
count = 0
|
||||
for entry in parsed.entries[:15]: # 每个源最多取 15 条
|
||||
title = str(entry.get("title", "")).strip()
|
||||
link = str(entry.get("link", "")).strip()
|
||||
if not title or not link:
|
||||
continue
|
||||
|
||||
# 解析发布日期
|
||||
published = None
|
||||
for date_field in ("published", "updated", "pubDate"):
|
||||
if entry.get(date_field):
|
||||
published = parse_date_str(entry.get(date_field))
|
||||
if published:
|
||||
break
|
||||
|
||||
content_html = "".join(str(c.get("value") or "") for c in entry.get("content", []))
|
||||
|
||||
# 提取摘要
|
||||
summary = ""
|
||||
if entry.get("summary"):
|
||||
raw = str(entry.get("summary", ""))
|
||||
summary_text = BeautifulSoup(raw, "html.parser").get_text(" ", strip=True)
|
||||
summary = re.split(r"\bThe post\b", summary_text, maxsplit=1, flags=re.I)[0].strip()[:200]
|
||||
|
||||
reference_text = BeautifulSoup(
|
||||
str(entry.get("summary") or ""),
|
||||
"html.parser",
|
||||
).get_text(" ", strip=True)
|
||||
if category_key == "fashion_trends" and not is_fashion_reference(title, reference_text):
|
||||
continue
|
||||
|
||||
# 提取缩略图
|
||||
thumbnail = ""
|
||||
if entry.get("media_content"):
|
||||
for mc in entry.media_content:
|
||||
if isinstance(mc, dict) and mc.get("url"):
|
||||
thumbnail = mc["url"]
|
||||
break
|
||||
if not thumbnail and entry.get("links"):
|
||||
for lk in entry.links:
|
||||
if isinstance(lk, dict) and lk.get("type", "").startswith("image"):
|
||||
thumbnail = lk.get("href", "")
|
||||
break
|
||||
|
||||
# 正则容错提取:从正文 HTML 中提取第一张 <img> 作为缩略图
|
||||
if not thumbnail:
|
||||
html_content = content_html or str(entry.get("summary") or "")
|
||||
if html_content:
|
||||
m_img = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html_content, re.I)
|
||||
if m_img:
|
||||
thumbnail = m_img.group(1)
|
||||
|
||||
items.append(TrendItem(
|
||||
source=feed_name,
|
||||
category=category_name,
|
||||
category_key=category_key,
|
||||
title=title,
|
||||
url=link,
|
||||
thumbnail=thumbnail,
|
||||
summary=summary,
|
||||
published_at=published,
|
||||
))
|
||||
count += 1
|
||||
|
||||
print(f" [免费趋势RSS] {feed_name}: {count} 条")
|
||||
|
||||
except Exception as exc:
|
||||
print(f" [免费趋势RSS] {feed_name} 抓取失败: {exc}")
|
||||
continue
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def fetch_curated_trend_pages(session: requests.Session, now: datetime) -> list[TrendItem]:
|
||||
"""抓取无稳定 RSS、但可公开验证的免费趋势页面。"""
|
||||
items: list[TrendItem] = []
|
||||
for page in CURATED_TREND_PAGES:
|
||||
try:
|
||||
response = session.get(page["url"], timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
title = (soup.select_one('meta[property="og:title"]') or {}).get("content", "")
|
||||
summary = (soup.select_one('meta[property="og:description"]') or {}).get("content", "")
|
||||
if not title and soup.title:
|
||||
title = soup.title.get_text(" ", strip=True)
|
||||
image = (soup.select_one('meta[property="og:image"]') or {}).get("content", "")
|
||||
published = ""
|
||||
for selector in ('meta[property="article:published_time"]', 'meta[name="date"]'):
|
||||
node = soup.select_one(selector)
|
||||
if node and node.get("content"):
|
||||
published = str(node.get("content"))
|
||||
break
|
||||
product_image = soup.select_one('img[src*="m.media-amazon.com"]')
|
||||
if product_image:
|
||||
image = str(product_image.get("src") or "")
|
||||
elif not image or image.endswith("/fb.png"):
|
||||
image_node = soup.select_one("img[src]")
|
||||
image = image_node.get("src", "") if image_node else ""
|
||||
if not title or not summary:
|
||||
raise ValueError("缺少可验证的标题或摘要")
|
||||
items.append(TrendItem(
|
||||
source=page["name"],
|
||||
category=page["category"],
|
||||
category_key=page["category_key"],
|
||||
title=title.strip(),
|
||||
url=page["url"],
|
||||
thumbnail=urljoin(page["url"], image),
|
||||
summary=summary.strip()[:300],
|
||||
published_at=parse_date_str(published),
|
||||
))
|
||||
print(f" [精选趋势页] {page['name']}: 1 条")
|
||||
except Exception as exc:
|
||||
print(f" [精选趋势页] {page['name']} 抓取失败: {exc}")
|
||||
return items
|
||||
|
||||
|
||||
# ────────────── Dezeen Showroom 网页抓取 ──────────────
|
||||
|
||||
def fetch_dezeen_showroom(session: requests.Session, now: datetime) -> list[TrendItem]:
|
||||
"""爬取 Dezeen Showroom 中的纺织面料与软装产品页面。"""
|
||||
import random
|
||||
items: list[TrendItem] = []
|
||||
category_name = "趋势博客"
|
||||
category_key = "trend_blogs"
|
||||
|
||||
for site in DEZEEN_SHOWROOM_URLS:
|
||||
name = site["name"]
|
||||
url = site["url"]
|
||||
|
||||
# 引入随机等待机制避开 WAF 的并发频率检测
|
||||
time.sleep(random.uniform(1.2, 3.0))
|
||||
|
||||
# 注入高仿真 Referer 和 Accept 头,拟合正常浏览器点击跳转行为
|
||||
headers = {
|
||||
"Referer": "https://www.dezeen.com/showroom/",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
|
||||
}
|
||||
|
||||
html_text = ""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
r = session.get(url, headers=headers, timeout=30)
|
||||
r.raise_for_status()
|
||||
html_text = r.text
|
||||
break
|
||||
except Exception as exc:
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = (attempt + 1) * 4
|
||||
print(f" [Dezeen Showroom] {name} 请求异常 (尝试 {attempt+1}/{max_retries}): {exc},等待 {wait_time} 秒后重试...")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
print(f" [Dezeen Showroom] {name} 抓取彻底失败: {exc}")
|
||||
|
||||
if not html_text:
|
||||
continue
|
||||
|
||||
try:
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
articles = soup.find_all("article")
|
||||
count = 0
|
||||
for art in articles:
|
||||
title_el = art.find("h3") or art.find("h2") or art.find("a")
|
||||
title = title_el.get_text(" ", strip=True) if title_el else ""
|
||||
|
||||
link_el = art.find("a")
|
||||
link = link_el.get("href") if link_el else ""
|
||||
if link and not link.startswith("http"):
|
||||
link = urljoin("https://www.dezeen.com", link)
|
||||
|
||||
if not title or not link:
|
||||
continue
|
||||
|
||||
# 从链接解析时间:形如 https://www.dezeen.com/2026/04/27/...
|
||||
published = None
|
||||
date_match = re.search(r"/(\d{4})/(\d{2})/(\d{2})/", link)
|
||||
if date_match:
|
||||
try:
|
||||
dt = datetime(int(date_match.group(1)), int(date_match.group(2)), int(date_match.group(3)), tzinfo=timezone.utc)
|
||||
published = iso(dt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
img_el = art.find("img")
|
||||
thumbnail = ""
|
||||
if img_el:
|
||||
thumbnail = img_el.get("src") or img_el.get("data-src") or ""
|
||||
|
||||
summary_el = art.find("p") or art.find(class_="summary")
|
||||
summary = summary_el.get_text(" ", strip=True) if summary_el else ""
|
||||
# 清理尾部
|
||||
if "More about" in summary:
|
||||
summary = summary.split("More about")[0].strip()
|
||||
|
||||
items.append(TrendItem(
|
||||
source=name,
|
||||
category=category_name,
|
||||
category_key=category_key,
|
||||
title=title,
|
||||
url=link,
|
||||
thumbnail=thumbnail,
|
||||
summary=summary,
|
||||
published_at=published,
|
||||
))
|
||||
count += 1
|
||||
|
||||
print(f" [Dezeen Showroom] {name}: {count} 条")
|
||||
except Exception as exc:
|
||||
print(f" [Dezeen Showroom] {name} 抓取失败: {exc}")
|
||||
continue
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# ────────────── TrendHunter 容错采集 ──────────────
|
||||
|
||||
def fetch_trendhunter(session: requests.Session, now: datetime) -> list[TrendItem]:
|
||||
"""容错尝试抓取 TrendHunter 流行趋势。若因 Akamai WAF 403 被阻断,将默默跳过不抛出异常。"""
|
||||
items: list[TrendItem] = []
|
||||
category_name = "趋势博客"
|
||||
category_key = "trend_blogs"
|
||||
source_name = "TrendHunter"
|
||||
|
||||
urls = [
|
||||
"https://www.trendhunter.com/hipfashion",
|
||||
"https://www.trendhunter.com/trends/textile-design",
|
||||
"https://www.trendhunter.com/trends/premium-home-textiles",
|
||||
"https://www.trendhunter.com/trends/furnishing-fabrics"
|
||||
]
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Referer": "https://www.google.com/"
|
||||
}
|
||||
|
||||
for url in urls:
|
||||
slug = url.split("/")[-1]
|
||||
try:
|
||||
r = session.get(url, headers=headers, timeout=15)
|
||||
if r.status_code == 403:
|
||||
print(f" [TrendHunter] {slug} 抓取受限 (HTTP 403),已自动跳过")
|
||||
continue
|
||||
r.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
# 查找卡片元素,采用多重 fallback
|
||||
cards = soup.select("div[class*='trendUnit'], div[class*='post-item'], .trend-card")
|
||||
if not cards:
|
||||
cards = [a for a in soup.find_all("a", href=True) if "/trends/" in a["href"] and len(a["href"]) > 20]
|
||||
|
||||
count = 0
|
||||
for card in cards:
|
||||
title = ""
|
||||
href = ""
|
||||
thumbnail = ""
|
||||
summary = ""
|
||||
|
||||
if card.name == "a":
|
||||
href = card["href"]
|
||||
title = card.get("title", "").strip() or card.get_text(" ", strip=True)
|
||||
img = card.find("img")
|
||||
if img:
|
||||
thumbnail = img.get("src") or img.get("data-src") or img.get("data-original") or ""
|
||||
if not title:
|
||||
title = img.get("alt", "").strip()
|
||||
else:
|
||||
a_link = card.find("a", href=True)
|
||||
if a_link:
|
||||
href = a_link["href"]
|
||||
title = a_link.get("title", "").strip() or a_link.get_text(" ", strip=True)
|
||||
|
||||
img = card.find("img")
|
||||
if img:
|
||||
thumbnail = img.get("src") or img.get("data-src") or img.get("data-original") or ""
|
||||
if not title:
|
||||
title = img.get("alt", "").strip()
|
||||
|
||||
p = card.find("p")
|
||||
if p:
|
||||
summary = p.get_text(" ", strip=True)
|
||||
|
||||
if not href:
|
||||
continue
|
||||
|
||||
if not href.startswith("http"):
|
||||
href = urljoin("https://www.trendhunter.com", href)
|
||||
|
||||
# 标题兜底
|
||||
if not title:
|
||||
m = re.search(r"/trends/([^/?#]+)", href)
|
||||
if m:
|
||||
title = m.group(1).replace("-", " ").title()
|
||||
|
||||
items.append(TrendItem(
|
||||
source=source_name,
|
||||
category=category_name,
|
||||
category_key=category_key,
|
||||
title=title,
|
||||
url=href,
|
||||
thumbnail=thumbnail,
|
||||
summary=summary,
|
||||
published_at=iso(now),
|
||||
))
|
||||
count += 1
|
||||
print(f" [TrendHunter] {slug} 成功抓取: {count} 条")
|
||||
|
||||
except Exception as exc:
|
||||
print(f" [TrendHunter] {slug} 抓取异常 (已自动跳过): {exc}")
|
||||
continue
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# ────────────── 博物馆灵感图库采集 ──────────────
|
||||
|
||||
def fetch_museum_images(session: requests.Session) -> list[TrendItem]:
|
||||
"""通过大都会艺术博物馆 (The Met) 和维多利亚与阿尔伯特博物馆 (V&A) 的公共 API 获取高清家纺设计大图。"""
|
||||
items: list[TrendItem] = []
|
||||
category_name = "灵感图库"
|
||||
category_key = "textile_gallery"
|
||||
|
||||
# 1. 抓取大都会艺术博物馆 (The Met) — 四大垂直主题并发抓取 (textile, tapestry, embroidery, brocade)
|
||||
met_queries = ["textile", "tapestry", "embroidery", "brocade"]
|
||||
|
||||
def fetch_single_met_query(q):
|
||||
target_ids = []
|
||||
try:
|
||||
search_url = f"https://collectionapi.metmuseum.org/public/collection/v1/search?q={q}&hasImages=true"
|
||||
res = session.get(search_url, timeout=20)
|
||||
if res.status_code == 200:
|
||||
object_ids = res.json().get("objectIDs", [])
|
||||
if object_ids:
|
||||
target_ids = object_ids[:10] # 每个查询取前 10 个
|
||||
except Exception as e:
|
||||
print(f" [博物馆图库] The Met 子查询 '{q}' 失败: {e}")
|
||||
return target_ids
|
||||
|
||||
# 并发获取所有 object ID 并去重
|
||||
met_object_ids = []
|
||||
with ThreadPoolExecutor(max_workers=4) as met_search_executor:
|
||||
met_search_futures = [met_search_executor.submit(fetch_single_met_query, q) for q in met_queries]
|
||||
for fut in as_completed(met_search_futures):
|
||||
o_ids = fut.result()
|
||||
for oid in o_ids:
|
||||
if oid not in met_object_ids:
|
||||
met_object_ids.append(oid)
|
||||
|
||||
# 并发请求 object 详情
|
||||
def fetch_met_detail(obj_id):
|
||||
detail_url = f"https://collectionapi.metmuseum.org/public/collection/v1/objects/{obj_id}"
|
||||
try:
|
||||
res = session.get(detail_url, timeout=12)
|
||||
if res.status_code == 200:
|
||||
return res.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
textile_terms = (
|
||||
"textile", "tapestry", "embroidery", "brocade", "fabric", "woven", "weaving",
|
||||
"cloth", "velvet", "silk", "cotton", "linen", "wool", "lace", "carpet", "rug",
|
||||
"quilt", "blanket", "coverlet", "bedcover", "curtain", "sampler",
|
||||
)
|
||||
|
||||
met_count = 0
|
||||
# 取前 30 个 object 详情,控制并发数量,防止 Met 的限流
|
||||
with ThreadPoolExecutor(max_workers=5) as met_detail_executor:
|
||||
met_detail_futures = {met_detail_executor.submit(fetch_met_detail, oid): oid for oid in met_object_ids[:30]}
|
||||
for fut in as_completed(met_detail_futures):
|
||||
detail = fut.result()
|
||||
if detail and (detail.get("primaryImageSmall") or detail.get("primaryImage")):
|
||||
title = str(detail.get("title") or "精美印花织物").strip()
|
||||
material = " ".join(str(detail.get(key) or "") for key in ("objectName", "medium", "classification", "title")).lower()
|
||||
if not any(term in material for term in textile_terms):
|
||||
continue
|
||||
culture = str(detail.get("culture") or "").strip()
|
||||
period = str(detail.get("period") or "").strip()
|
||||
date = str(detail.get("objectDate") or "").strip()
|
||||
|
||||
parts = ["大都会博物馆藏"]
|
||||
if culture:
|
||||
parts.append(f"工艺文化: {culture}")
|
||||
if detail.get("medium"):
|
||||
parts.append(f"材质: {str(detail['medium']).strip()}")
|
||||
if period or date:
|
||||
parts.append(f"年代: {period or date}")
|
||||
summary = " | ".join(parts)
|
||||
|
||||
items.append(TrendItem(
|
||||
source="The Met",
|
||||
category=category_name,
|
||||
category_key=category_key,
|
||||
title=title,
|
||||
url=f"https://www.metmuseum.org/art/collection/search/{detail.get('objectID')}",
|
||||
thumbnail=detail.get("primaryImageSmall") or detail.get("primaryImage"),
|
||||
image_original=detail.get("primaryImage") or detail.get("primaryImageSmall"),
|
||||
summary=summary,
|
||||
))
|
||||
met_count += 1
|
||||
print(f" [博物馆图库] The Met 成功提取并合并: {met_count} 张高清图")
|
||||
|
||||
# 2. 抓取维多利亚与阿尔伯特博物馆 (V&A) — 细分多渠道并发抓取 (技术、壁挂、纹样、刺绣)
|
||||
va_queries = [
|
||||
{"name": "V&A 工艺技术", "url": "https://api.vam.ac.uk/v2/objects/search?id_technique=x40351&q=textile&images_exist=1&page_size=15"},
|
||||
{"name": "V&A 壁挂艺术", "url": "https://api.vam.ac.uk/v2/objects/search?q=Hanging&images_exist=1&page_size=15"},
|
||||
{"name": "V&A 经典纹样", "url": "https://api.vam.ac.uk/v2/objects/search?q=Pattern&images_exist=1&page_size=15"},
|
||||
{"name": "V&A 刺绣艺术", "url": "https://api.vam.ac.uk/v2/objects/search?q=embroidery&images_exist=1&page_size=15"},
|
||||
]
|
||||
|
||||
def fetch_single_va_query(q_info):
|
||||
va_items = []
|
||||
try:
|
||||
res = session.get(q_info["url"], timeout=20)
|
||||
if res.status_code == 200:
|
||||
records = res.json().get("records", [])
|
||||
for rec in records:
|
||||
img_info = rec.get("_images", {})
|
||||
image_url = img_info.get("_primary_thumbnail")
|
||||
if image_url:
|
||||
thumb = image_url.replace("!100,100", "!600,")
|
||||
original = image_url.replace("!100,100", "full")
|
||||
else:
|
||||
continue
|
||||
|
||||
primary_title = str(rec.get("_primaryTitle") or "").strip()
|
||||
object_type = str(rec.get("objectType") or "").strip()
|
||||
system_number = str(rec.get("systemNumber") or "").strip()
|
||||
title = primary_title or object_type or "经典纺织藏品"
|
||||
if not primary_title and system_number and object_type.casefold() in VAM_GENERIC_TITLE_ZH:
|
||||
title = f"{object_type} [{system_number}]"
|
||||
maker = rec.get("_primaryMaker", {}).get("name") if rec.get("_primaryMaker") else None
|
||||
place = rec.get("_primaryPlace")
|
||||
date = rec.get("_primaryDate")
|
||||
|
||||
parts = ["V&A 博物馆藏"]
|
||||
if maker:
|
||||
parts.append(f"制造/设计: {maker}")
|
||||
if place or date:
|
||||
parts.append(f"产地年代: {place or '未知'} ({date or '未知'})")
|
||||
summary = " | ".join(parts)
|
||||
|
||||
va_items.append(TrendItem(
|
||||
source="V&A Museum",
|
||||
category=category_name,
|
||||
category_key=category_key,
|
||||
title=title,
|
||||
url=f"https://collections.vam.ac.uk/item/{rec.get('systemNumber')}",
|
||||
thumbnail=thumb,
|
||||
image_original=original,
|
||||
summary=summary,
|
||||
))
|
||||
else:
|
||||
print(f" [博物馆图库] {q_info['name']} 返回状态受限 (HTTP {res.status_code})")
|
||||
except Exception as e:
|
||||
print(f" [博物馆图库] {q_info['name']} 请求异常: {e}")
|
||||
return va_items
|
||||
|
||||
va_count = 0
|
||||
with ThreadPoolExecutor(max_workers=4) as va_executor:
|
||||
va_futures = [va_executor.submit(fetch_single_va_query, q) for q in va_queries]
|
||||
for fut in as_completed(va_futures):
|
||||
res_items = fut.result()
|
||||
# 合并并去重图片/系统编号链接
|
||||
for item in res_items:
|
||||
if not any(x.url == item.url for x in items):
|
||||
items.append(item)
|
||||
va_count += 1
|
||||
print(f" [博物馆图库] V&A Museum 成功提取并合并: {va_count} 张高清图")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
|
||||
# ────────────── 数据汇总 & 输出 ──────────────
|
||||
|
||||
|
||||
def collect_all_trends(session: requests.Session, now: datetime, cache: dict[str, str]) -> dict[str, Any]:
|
||||
"""并发采集所有数据源,汇总为输出 payload。"""
|
||||
|
||||
print("\n[开始] 家纺流行趋势数据采集")
|
||||
print("=" * 60)
|
||||
|
||||
all_items: list[TrendItem] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# POP 趋势网需要付费账号,本项目不再采集;图片搜索改为零抓取成本的前端快捷入口。
|
||||
tasks = {
|
||||
"免费趋势RSS": lambda: fetch_free_trend_rss(session, now),
|
||||
"精选趋势页": lambda: fetch_curated_trend_pages(session, now),
|
||||
"Dezeen展厅": lambda: fetch_dezeen_showroom(session, now),
|
||||
"博物馆图库": lambda: fetch_museum_images(session),
|
||||
"TrendHunter": lambda: fetch_trendhunter(session, now),
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
futures = {executor.submit(fn): name for name, fn in tasks.items()}
|
||||
for future in as_completed(futures):
|
||||
name = futures[future]
|
||||
try:
|
||||
result = future.result(timeout=90)
|
||||
all_items.extend(result)
|
||||
except Exception as exc:
|
||||
error_msg = f"{name}: {exc}"
|
||||
errors.append(error_msg)
|
||||
print(f" [ERROR] {error_msg}")
|
||||
|
||||
print(f"\n[汇总] 共采集 {len(all_items)} 条趋势数据")
|
||||
|
||||
# 按分类分组
|
||||
categories: dict[str, dict[str, Any]] = {}
|
||||
seen_urls: set[str] = set()
|
||||
|
||||
# 限制单次运行新翻译上限,防止高频调用被谷歌限制IP
|
||||
max_new_translations = 250
|
||||
translated_count = 0
|
||||
|
||||
# 优先将最新发布的文章排在前面,确保首屏最新文章必定获得翻译
|
||||
all_items.sort(
|
||||
key=lambda x: x.published_at or "1970-01-01",
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for item in all_items:
|
||||
blocked_text = f"{item.category_key} {item.source} {item.url}".lower()
|
||||
if any(marker in blocked_text for marker in BLOCKED_PAID_TREND_MARKERS):
|
||||
continue
|
||||
# 针对灵感图库,如果没有缩略图,则直接跳过不录入,保障不出现空白卡片
|
||||
if item.category_key == "textile_gallery" and not item.thumbnail:
|
||||
continue
|
||||
|
||||
# 去重
|
||||
if item.url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(item.url)
|
||||
|
||||
title = item.title
|
||||
summary = item.summary
|
||||
title_original = ""
|
||||
summary_original = ""
|
||||
|
||||
# 针对外部英文源以及灵感图库进行免费汉化翻译
|
||||
if item.category_key in ("trend_blogs", "fashion_trends", "textile_gallery"):
|
||||
if title:
|
||||
title_original = title
|
||||
curated_title = curate_vam_title(title) if item.source == "V&A Museum" else ""
|
||||
if curated_title:
|
||||
title = curated_title
|
||||
elif title in cache:
|
||||
title = cache[title]
|
||||
elif translated_count < max_new_translations:
|
||||
translated = translate_to_zh_cn(session, title)
|
||||
if translated:
|
||||
cache[title] = translated
|
||||
title = translated
|
||||
translated_count += 1
|
||||
|
||||
if summary:
|
||||
summary_original = summary
|
||||
if summary in cache:
|
||||
summary = cache[summary]
|
||||
elif translated_count < max_new_translations:
|
||||
translated = translate_to_zh_cn(session, summary)
|
||||
if translated:
|
||||
cache[summary] = translated
|
||||
summary = translated
|
||||
translated_count += 1
|
||||
|
||||
if item.category_key not in categories:
|
||||
categories[item.category_key] = {
|
||||
"name": item.category,
|
||||
"source": item.source,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
id_title = re.sub(r"\s+\[O\d+\]\s*$", "", item.title, flags=re.IGNORECASE) if item.source == "V&A Museum" else item.title
|
||||
item_dict = {
|
||||
"id": make_trend_id(item.source, id_title, item.url),
|
||||
"title": title,
|
||||
"title_original": title_original,
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"category": item.category,
|
||||
"category_key": item.category_key,
|
||||
"thumbnail": item.thumbnail,
|
||||
"image_original": item.image_original,
|
||||
"summary": summary,
|
||||
"summary_original": summary_original,
|
||||
"published_at": item.published_at,
|
||||
}
|
||||
item_dict.update(translation_fields(
|
||||
item.url,
|
||||
source_text=f"{title_original or item.title} {summary_original or item.summary}",
|
||||
))
|
||||
categories[item.category_key]["items"].append(item_dict)
|
||||
|
||||
# 馆藏没有新闻发布时间,按来源和永久链接稳定排序;其他分类仍按发布时间倒序。
|
||||
for category_key, cat_data in categories.items():
|
||||
if category_key == "textile_gallery":
|
||||
cat_data["items"].sort(key=lambda x: (x.get("source", ""), x.get("url", "")))
|
||||
else:
|
||||
cat_data["items"].sort(
|
||||
key=lambda x: (
|
||||
bool(x.get("thumbnail")) if category_key == "fashion_trends" else False,
|
||||
x.get("published_at") or "1970-01-01",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
cat_data["count"] = len(cat_data["items"])
|
||||
|
||||
total_items = sum(len(c["items"]) for c in categories.values())
|
||||
|
||||
payload = {
|
||||
"generated_at": iso(now),
|
||||
"total_items": total_items,
|
||||
"category_count": len(categories),
|
||||
"source_count": len({i.source for i in all_items}),
|
||||
"categories": categories,
|
||||
"errors": errors if errors else None,
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def validate_payload(payload: dict[str, Any]) -> None:
|
||||
def walk(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
walk(item, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
walk(item, f"{path}[{index}]")
|
||||
elif isinstance(value, str) and "????" in value:
|
||||
raise ValueError(f"前台数据文本损坏: {path}")
|
||||
|
||||
walk(payload)
|
||||
|
||||
|
||||
# ────────────── 主函数 ──────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="家纺流行趋势数据采集")
|
||||
parser.add_argument("--output-dir", default="data", help="输出目录")
|
||||
args = parser.parse_args()
|
||||
|
||||
now = utc_now()
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_path = output_dir / "textile-trends.json"
|
||||
|
||||
session = create_session()
|
||||
cache = load_zh_cache(output_dir)
|
||||
payload = collect_all_trends(session, now, cache)
|
||||
validate_payload(payload)
|
||||
|
||||
output_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
save_zh_cache(output_dir, cache)
|
||||
|
||||
print(f"\n[完成] 数据已写入: {output_path}")
|
||||
print(f" 总条目: {payload['total_items']}")
|
||||
print(f" 分类数: {payload['category_count']}")
|
||||
print(f" 数据源: {payload['source_count']}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user