#!/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'(.*?)', raw, re.S | re.I): title_match = re.search(r'
(.*?)
', block, re.S | re.I) description_match = re.search(r'