600 lines
31 KiB
Python
600 lines
31 KiB
Python
#!/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())
|