同步家纺情报 v2 数据、59 个严格权威源、趋势图库、移动端交互、Google/Yandex 翻译门控及静态 SEO/GEO 构建。 补充面向 C# 研发的 README,明确当前无 LLM、多模态与生图模型,记录翻译请求参数、缓存与失败策略、数据处理边界、前端职责和无 GitHub Pages 部署流程。 新增按日期倒序维护的 CHANGELOG,记录影响、验证与代码/数据/dist 回滚方法。 验证:11 个 Python 契约测试、13 个 Node 前端测试通过;静态构建生成 1096 个 Sitemap URL。
656 lines
32 KiB
Python
656 lines
32 KiB
Python
#!/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)
|