#!/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'
(.*?)
', block, re.S | re.I) image_match = re.search(r']+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('
') article_end = raw.find("", article_start) article = raw[article_start:article_end] if article_start >= 0 else raw official_url = "" for url, anchor_text in re.findall(r']*>(.*?)', 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'
  • (.*?)
  • ', raw, re.S | re.I): def field(class_name: str) -> str: match = re.search(r'class="' + re.escape(class_name) + r'"[^>]*>(.*?) 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)