# 小函智能导购 · 完整源码包 > 生成时间:2026-11-05 17:48 > Python 3.14 · 零第三方依赖 · 纯标准库 ThreadingHTTPServer · 原生 ES module 前端 > 启动:`python server.py` 默认端口 8787,可通过 `PORT` 环境变量覆盖 > 可选:设置 `LLM_API_KEY` / `LLM_API_BASE` / `LLM_MODEL` 启用大模型解说层(失败自动回落模板) ## 文件索引 - server.py- engine.py- db.py- linkparse.py- websearch.py- xlsx_export.py- tools/build_catalog.py- tools/gen_models.py- web/index.html- web/css/style.css- web/js/app.js- web/js/voice.js- data/products.json- data/catalog.json- data/dialects.json --- ## server.py ```python # -*- coding: utf-8 -*- """智能导购 HTTP 服务(纯标准库,零第三方依赖) 启动:python server.py 默认 http://127.0.0.1:8787 可选:设置 LLM_API_KEY / LLM_API_BASE / LLM_MODEL 启用大模型解说 """ import json import os import posixpath import re import socket import sys import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import engine BASE_DIR = os.path.dirname(os.path.abspath(__file__)) WEB_DIR = os.path.join(BASE_DIR, "web") EXPORTS_DIR = os.path.join(BASE_DIR, "exports") PORT = int(os.environ.get("PORT", "8787")) MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".glb": "model/gltf-binary", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".ico": "image/x-icon", } class Handler(BaseHTTPRequestHandler): server_version = "SmartShopping/1.0" # ------------------------------------------------------------ 基础工具 def log_message(self, fmt, *args): sys.stdout.write("[%s] %s\n" % (self.log_date_time_string(), fmt % args)) def _send(self, body, status=200, content_type="application/json; charset=utf-8", extra=None): if isinstance(body, (dict, list)): body = json.dumps(body, ensure_ascii=False).encode("utf-8") elif isinstance(body, str): body = body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("Access-Control-Allow-Origin", "*") if extra: for k, v in extra.items(): self.send_header(k, v) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def _json(self, obj, status=200): self._send(obj, status) def _read_body(self): length = int(self.headers.get("Content-Length") or 0) if not length: return {} raw = self.rfile.read(length) try: return json.loads(raw.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): return {} def _query(self): parsed = urllib.parse.urlparse(self.path) return parsed.path.rstrip("/") or "/", urllib.parse.parse_qs(parsed.query) def _serve_static(self, path): rel = path.lstrip("/") or "index.html" if ".." in rel: self._json({"error": "bad path"}, 400) return full = os.path.join(WEB_DIR, rel) if not os.path.isfile(full): self._send("Not Found", 404, "text/plain; charset=utf-8") return ext = posixpath.splitext(rel)[1].lower() with open(full, "rb") as f: data = f.read() self._send(data, 200, MIME.get(ext, "application/octet-stream")) # ------------------------------------------------------------ GET def do_GET(self): path, query = self._query() try: if path == "/api/config": return self._json(engine.config_info()) if path == "/api/products": brand = (query.get("brand") or [""])[0] scene = (query.get("scene") or [""])[0] uid = (query.get("userId") or ["anon"])[0] profile = engine.get_profile(uid) items = engine._all_products() if brand: items = [p for p in items if p["brand"] == brand] if scene: items = [p for p in items if scene in p.get("scenes", [])] return self._json({ "products": [engine.product_card(p, profile) for p in items], "total": len(items), }) if path == "/api/recommend": uid = (query.get("userId") or ["anon"])[0] limit = int((query.get("limit") or ["6"])[0]) return self._json(engine.recommend_feed(uid, limit=limit)) if path == "/api/memory": uid = (query.get("userId") or ["anon"])[0] profile = engine.get_profile(uid) return self._json({"profile": engine.profile_summary(profile), "raw": profile}) if path == "/api/product": pid = (query.get("id") or [""])[0] uid = (query.get("userId") or ["anon"])[0] detail = engine.product_detail(pid, engine.get_profile(uid)) if not detail: return self._json({"error": "not found"}, 404) return self._json(detail) if path.startswith("/api/"): return self._json({"error": "unknown endpoint"}, 404) if path.startswith("/download/"): name = path[len("/download/"):] if not re.fullmatch(r"[A-Za-z0-9_.-]+\.xlsx", name): return self._json({"error": "bad file name"}, 400) full = os.path.join(EXPORTS_DIR, name) if not os.path.isfile(full): return self._send("Not Found", 404, "text/plain; charset=utf-8") display = (query.get("name") or [name])[0] display = re.sub(r'[\\/:*?"<>|\r\n]+', "_", display)[:80] or name with open(full, "rb") as f: data = f.read() disposition = ( f'attachment; filename="{name}"; ' f"filename*=UTF-8''{urllib.parse.quote(display)}" ) return self._send(data, 200, MIME[".xlsx"], extra={ "Content-Disposition": disposition, }) return self._serve_static(path) except Exception as exc: # noqa: BLE001 self._json({"error": str(exc)}, 500) # ------------------------------------------------------------ POST def do_POST(self): path, _ = self._query() data = self._read_body() uid = data.get("userId") or "anon" try: if path == "/api/chat": message = (data.get("message") or "").strip() if not message: return self._json({"error": "message 不能为空"}, 400) result = engine.handle_chat(uid, message, data.get("dialect")) return self._json(result) if path == "/api/compare": ids = data.get("ids") or [] if len(ids) < 2: return self._json({"error": "至少需要 2 个商品"}, 400) parsed = {"scenes": data.get("scenes") or [], "brands": [], "intent": "compare"} profile = engine.get_profile(uid) result = engine.build_compare(ids, profile, parsed) for pid in result["ids"]: engine.record_event(uid, "compare", pid) engine._remember_compare(uid, engine._compare_export_model(result), result["ids"]) return self._json({"compare": result}) if path == "/api/event": product_id = data.get("productId") if not product_id: return self._json({"error": "productId 不能为空"}, 400) event_type = data.get("type") or "view" profile = engine.record_event(uid, event_type, product_id) return self._json({"ok": True, "profile": engine.profile_summary(profile)}) if path == "/api/settings": profile = engine.set_setting(uid, "avoidNew", bool(data.get("avoidNew", True))) return self._json({"ok": True, "profile": engine.profile_summary(profile)}) if path == "/api/reset": engine.reset_memory(uid) return self._json({"ok": True}) return self._json({"error": "unknown endpoint"}, 404) except Exception as exc: # noqa: BLE001 self._json({"error": str(exc)}, 500) # ------------------------------------------------------------ DELETE def do_DELETE(self): path, query = self._query() if path == "/api/memory": uid = (query.get("userId") or ["anon"])[0] engine.reset_memory(uid) return self._json({"ok": True}) return self._json({"error": "unknown endpoint"}, 404) def do_OPTIONS(self): self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() def _local_ip(): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("0.0.0.0", 0)) return s.getsockname()[0] except OSError: return "127.0.0.1" def main(): ip = _local_ip() print("=" * 58) print(" 小函智能导购已启动") print(f" 本机访问:http://127.0.0.1:{PORT}") print(f" 手机访问:http://{ip}:{PORT} (需同一局域网)") print(f" 商品数量:{len(engine.PRODUCTS)} 大模型解说:{'已启用' if engine.llm_enabled() else '未启用(模板话术)'}") print("=" * 58) server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) try: server.serve_forever() except KeyboardInterrupt: print("\n已停止服务") server.server_close() if __name__ == "__main__": main() ``` --- ## engine.py ```python # -*- coding: utf-8 -*- """智能导购核心引擎 能力: 1. 方言归一化 + 中文数字解析 + 需求抽取(预算/品牌/场景/具体机型) 2. 混合检索引擎(规则 + TF-IDF 语义向量 + 大模型查询理解 + 联网商品补充) 3. 价格 / 好评 / 销量 对比表生成 4. 用户记忆库(历史搜索、浏览、点击、偏好权重) 5. 可选大模型解说层(配置 LLM_API_KEY 后启用,失败自动回落模板) """ import math import json import os import re import threading import time import urllib.parse from datetime import datetime import websearch import linkparse import xlsx_export import db BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") _lock = threading.RLock() db.init() PRODUCTS = db.load_products() with open(os.path.join(DATA_DIR, "dialects.json"), "r", encoding="utf-8") as f: DIALECTS = json.load(f) PRODUCT_BY_ID = {p["id"]: p for p in PRODUCTS} def web_products(): return db.load_products(origin="web") def _all_products(): return db.load_products() def product_by_id(pid): return db.load_product(pid) or PRODUCT_BY_ID.get(pid) # ---------------------------------------------------------------- 机型别名 EXTRA_ALIASES = { "mi15": ["小米15", "米15", "mi15", "小米十五", "小屏旗舰"], "mi15pro": ["小米15pro", "小米十五pro", "mi15pro", "15pro", "小米 15 pro"], "mi14": ["小米14", "米14", "mi14"], "k80": ["红米k80", "k80", "redmi k80"], "turbo4": ["红米turbo4", "turbo4", "turbo 4", "红米turbo"], "mixflip": ["mix flip", "mixflip", "小米折叠", "小折叠"], "ip16": ["iphone16", "苹果16", "ip16", "苹果十六", "ipone16"], "ip16pro": ["iphone16pro", "苹果16pro", "16pro", "苹果十六pro"], "ip15": ["iphone15", "苹果15", "ip15"], "ip16plus": ["iphone16plus", "苹果16plus", "16plus"], "mate70": ["mate70", "华为mate70", "mate 70", "mate70pro"], "nova13": ["nova13", "华为nova13", "nova 13"], "pura70": ["pura70", "华为pura70", "pura 70"], "magic7": ["magic7", "荣耀magic7", "magic 7"], "findx8": ["findx8", "find x8", "oppo findx8", "findx8pro"], "x200": ["x200", "vivo x200", "vivox200"], "oneplus13": ["一加13", "oneplus13", "加13", "一加十三"], "s25": ["s25", "galaxy s25", "三星s25", "s25ultra"], } def _build_alias_index(): idx = {} for p in PRODUCTS: for alias in [p["id"], p["name"], p["name"].replace(" ", "")]: idx.setdefault(alias.lower(), p["id"]) for alias in EXTRA_ALIASES.get(p["id"], []): idx.setdefault(alias.lower(), p["id"]) return idx ALIAS_INDEX = _build_alias_index() ALIASES_SORTED = sorted(ALIAS_INDEX.keys(), key=len, reverse=True) # ---------------------------------------------------------------- 词典 BRANDS = { "小米": ["小米", "红米", "redmi", "米家", "mi"], "苹果": ["苹果", "iphone", "ipone", "果子", "爱疯", "库克", "ios"], "华为": ["华为", "huawei", "菊花", "mate", "pura", "nova"], "荣耀": ["荣耀", "honor", "magic"], "OPPO": ["oppo", "欧珀", "findx"], "vivo": ["vivo", "维沃"], "一加": ["一加", "oneplus"], "三星": ["三星", "samsung", "galaxy"], } SCENES = { "拍照": ["拍照", "相机", "摄影", "影像", "拍人", "自拍", "出片", "长焦", "夜景", "视频", "拍视频", "拍娃", "拍演唱会", "演唱会"], "游戏": ["游戏", "电竞", "打游戏", "原神", "王者", "吃鸡", "帧率", "性能强", "性能党", "游戏党", "吃鸡"], "续航": ["续航", "电池", "电量", "耐用", "待机", "一天", "两天", "不用充电", "续航久"], "快充": ["快充", "充电快", "充得快", "百瓦", "闪充"], "小屏": ["小屏", "小巧", "单手", "小尺寸", "手小", "小一点"], "大屏": ["大屏", "屏幕大", "看剧", "追剧", "看视频", "大一点"], "轻薄": ["轻薄", "轻", "薄", "手感", "重量", "不重", "轻一点"], "性价比": ["性价比", "便宜", "划算", "实惠", "省钱", "价格低", "学生", "学生党", "预算少", "不贵", "亲民"], "商务": ["商务", "办公", "会议", "老板", "体面", "有面子", "高端"], "折叠": ["折叠", "小折叠", "大折叠", "折叠屏"], "系统": ["流畅", "系统", "生态", "ios", "苹果生态", "鸿蒙", "不卡", "卡顿", "稳定"], "自拍": ["自拍", "前置", "美颜"], "信号": ["信号", "通话", "地下车库", "电梯"], "办公": ["办公", "文档", "多任务"], "性能": ["性能", "顶配", "旗舰", "跑分", "处理器"], "音乐": ["音乐", "音质", "听歌"], "通勤": ["通勤", "上下班", "地铁"], "健康": ["健康", "心率", "血氧", "睡眠"], "居家": ["居家", "在家", "家里用", "宿舍"], "护肤": ["护肤", "保湿", "精华", "面霜"], "运动": ["运动", "跑步", "骑行", "健身"], "户外": ["户外", "露营", "徒步", "爬山"], "零食": ["零食", "吃的", "加餐", "坚果"], } # 商品库里的品牌自动并入品牌词典(多品类后品牌远多于手机) for _brand in {p.get("brand") for p in PRODUCTS if p.get("brand")}: BRANDS.setdefault(_brand, [_brand.lower()]) SPEC_KEYS = [ ("chip", "处理器"), ("screen", "屏幕"), ("battery", "电池"), ("charge", "充电"), ("camera", "影像"), ("weight", "重量"), ("storage", "存储"), ("system", "系统"), ] # 品类词典:用户说这些词就把品类锁定 CATEGORIES = { "手机": ["手机", "手机壳", "旗舰机", "千元机"], "笔记本": ["笔记本", "电脑", "笔电", "macbook", "游戏本", "显示器"], "平板": ["平板", "pad", "ipad"], "耳机": ["耳机", "耳塞", "蓝牙耳机", "降噪耳", "音箱", "蓝牙音箱"], "智能穿戴": ["手表", "手环", "watch", "穿戴"], "家电": ["空调", "冰箱", "洗衣机", "扫地机", "扫地机器人", "豆浆机", "破壁机", "破壁", "吸尘器", "电饭煲", "空气炸锅", "咖啡机", "吹风机", "空气净化器", "加湿器", "微波炉", "烤箱", "洗碗机", "家电"], "美妆": ["美妆", "护肤", "精华", "口红", "蜜粉", "面霜", "化妆品", "面膜", "洗面奶", "粉底", "眼影", "香水", "防晒"], "食品": ["零食", "坚果", "牛奶", "大米", "饮料", "泡面", "食品", "咖啡豆", "面包", "饼干", "酸奶"], "服饰": ["衣服", "外套", "冲锋衣", "跑鞋", "鞋", "内衣", "穿搭", "羽绒服", "T恤", "裤子", "帽子"], "家居": ["家居", "沙发", "餐桌", "书桌", "香薰", "蜡烛", "家具", "床垫", "枕头", "台灯", "地毯", "收纳"], } # 生活场景细分:室内 / 室外 ENVIRONMENTS = { "indoor": ["室内", "家里", "家用", "居家", "办公", "卧室", "客厅", "厨房", "宿舍", "书房"], "outdoor": ["室外", "户外", "出门", "出行", "通勤", "旅行", "旅游", "露营", "运动", "跑步", "爬山", "骑行", "自驾"], } BRAND_TIER_LABELS = {"luxury": "高奢", "popular": "热门", "economy": "经济", "niche": "小众"} # ---------------------------------------------------------------- 数字处理 CN_DIGITS = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} CN_UNITS = {"十": 10, "百": 100, "千": 1000, "万": 10000} def cn2num(text): """把 '五千' '三千二' '一万二' 这类中文数字转成整数,失败返回 None。""" total, section, number, last_unit = 0, 0, 0, 0 for ch in text: if ch in CN_DIGITS: number = CN_DIGITS[ch] elif ch in CN_UNITS: unit = CN_UNITS[ch] if unit == 10000: section = (section + number) * unit total += section section = 0 else: if number == 0: number = 1 section += number * unit last_unit = unit number = 0 else: return None if number: if last_unit and last_unit >= 100: section += number * (last_unit // 10) else: section += number return total + section def normalize_numbers(text): """只转换带'十百千万'单位的中文数字,避免误伤'一加13'。""" def repl(m): token = m.group() if not any(ch in CN_UNITS for ch in token): return token value = cn2num(token) return str(value) if value else token return re.sub(r"[零〇一二两三四五六七八九十百千万]+", repl, text) def fmt_sales(n): if n >= 10000: return f"{n / 10000:.1f}万".replace(".0万", "万") return str(n) # ---------------------------------------------------------------- 方言归一化 def normalize_dialect(text, dialect_hint=None): """识别并归一化方言词,返回 (归一化文本, 命中方言信息或 None)。""" hits = [] result = text for region in DIALECTS["regions"]: matched = [m for m in region["markers"] if m in text] if not matched: continue hits.append({"code": region["code"], "name": region["name"], "markers": matched[:3]}) for phrase in sorted(region["phrases"].keys(), key=len, reverse=True): if phrase in result: result = result.replace(phrase, region["phrases"][phrase]) if hits: return result, hits[0] if dialect_hint: for region in DIALECTS["regions"]: if region["code"] == dialect_hint: return result, {"code": region["code"], "name": region["name"], "markers": []} return result, None def detect_product_mentions(text): """最长优先 + 从左到右扫描,命中即消费,避免"小米15 Pro"同时命中小米15。""" padded = text.lower().replace(" ", "") found = [] i = 0 while i < len(padded): hit = None for alias in ALIASES_SORTED: key = alias.replace(" ", "") if key and padded.startswith(key, i): hit = (key, ALIAS_INDEX[alias]) break if hit: if hit[1] not in found: found.append(hit[1]) i += len(hit[0]) else: i += 1 return found # ---------------------------------------------------------------- 需求解析 def parse_budget(text): t = normalize_numbers(text) m = re.search(r"(\d+(?:\.\d+)?)\s*[kK千万]\s*[-~—至到]\s*(\d+(?:\.\d+)?)\s*[kK千万]", t) if m: lo, hi = float(m.group(1)) * 1000, float(m.group(2)) * 1000 return {"min": int(lo), "max": int(hi), "label": f"{int(lo)}-{int(hi)}元", "type": "range"} m = re.search(r"(\d{3,6})\s*(?:元|块)?\s*[-~—至到]\s*(\d{3,6})", t) if m: lo, hi = int(m.group(1)), int(m.group(2)) return {"min": min(lo, hi), "max": max(lo, hi), "label": f"{min(lo, hi)}-{max(lo, hi)}元", "type": "range"} if "千元机" in t or "百元机" in t: return {"min": 0, "max": 2000, "label": "2000元以内", "type": "max"} m = re.search(r"预算(?:是|在|大概)?\s*(\d{3,6})", t) if m: v = int(m.group(1)) return {"min": 0, "max": int(v * 1.15), "label": f"预算{v}元左右", "type": "max"} m = re.search(r"(\d{3,6})\s*(?:元|块)?\s*(?:以内|以下|之内|封顶)", t) if m: return {"min": 0, "max": int(m.group(1)), "label": f"{m.group(1)}元以内", "type": "max"} m = re.search(r"(?:不超过|不到|少于|最多|低于)\s*(\d{3,6})", t) if m: return {"min": 0, "max": int(m.group(1)), "label": f"{m.group(1)}元以内", "type": "max"} m = re.search(r"(\d{3,6})\s*(?:元|块)?\s*(?:左右|上下|附近|价位|档|的)", t) if m: v = int(m.group(1)) return {"min": int(v * 0.85), "max": int(v * 1.15), "label": f"{v}元左右", "type": "around"} m = re.search(r"(\d{3,6})\s*(?:元|块)?\s*(?:以上|起步|往上)", t) if m: return {"min": int(m.group(1)), "max": 99999, "label": f"{m.group(1)}元以上", "type": "min"} m = re.search(r"(\d{3,6})\s*(?:元|块)", t) if m: v = int(m.group(1)) return {"min": int(v * 0.85), "max": int(v * 1.15), "label": f"{v}元左右", "type": "around"} return None def parse_scenes(text): found = [] for scene, words in SCENES.items(): for w in words: if w in text: if scene not in found: found.append(scene) break return found def parse_categories(text): low = text.lower() found = [] for category, words in CATEGORIES.items(): for word in words: if word.lower() in low: found.append(category) break return found def parse_environments(text): low = text.lower() found = [] for env, words in ENVIRONMENTS.items(): for word in words: if word.lower() in low: found.append(env) break return found def parse_brand_tiers(text): labels = {"luxury": ["高奢", "高端", "奢侈"], "popular": ["热门", "爆款", "畅销"], "economy": ["经济", "平价", "便宜", "省钱"], "niche": ["小众", "独特", "冷门"]} low = text.lower() found = [] for tier, words in labels.items(): for word in words: if word in low: found.append(tier) break return found def parse_brands(text): found = [] low = text.lower() for brand, words in BRANDS.items(): for w in words: if w.lower() in low: if brand not in found: found.append(brand) break return found def extract_keywords(text): """抽出非功能词,用于自由文本匹配(先去掉价格表达和虚词/语气词)。""" cleaned = re.sub(r"[,。!?、,.!?::\s]", " ", text) # 先清掉预算表达,避免“2000以内的扫地机器人”把数字并进关键词 cleaned = re.sub( r"\d+(?:\.\d+)?\s*(?:k|K|千|万)?\s*(?:元|块)?\s*(?:以内|以下|之内|封顶|左右|上下|附近|以上|起步|价位|档|到)?\s*的?", " ", cleaned) stop = { "我要", "想买", "买个", "买一", "一部", "的", "款", "个", "机型", "请问", "我", "有", "没", "比较", "对比", "推荐", "一下", "啊", "呀", "吗", "呢", "吧", "呗", "哦", "嘛", "就", "都", "也", "还", "和", "跟", "比", "哪个", "哪款", "好", "选", "最", "看", "看看", "求", "帮", "查", "给我", "点", "些", "大概", "左右", "以内", "以上", "预算", "元", "块", "钱", "价格", "价位", "要求", "能", "可以", "不", "手机", } out = [] for tk in re.split(r"\s+", cleaned): tk = tk.strip() if not tk or tk.isdigit() or tk in stop or len(tk) < 2: continue out.append(tk) return out[:6] def _kw_variants(kw): """品类词去掉“机/器/表”后缀再匹配一次,兼容“破壁机”↔“破壁豆浆机”这类写法。""" out = [kw] if len(kw) >= 3 and kw[-1] in "机器表仪": out.append(kw[:-1]) return out COMPARE_RE = re.compile(r"对比|比较|哪个好|哪款好|哪个更|区别|差异|差在哪|选哪个|买哪个|还是|vs|VS|PK|pk") EXCLUDE_RE = re.compile(r"(?:不要|别要|不想要|不想买|不喜欢|避开|排除|除开)\s*([\u4e00-\u9fa5A-Za-z0-9]{1,6})") def parse_excludes(text): items = [] for m in EXCLUDE_RE.finditer(text): value = m.group(1).strip() if value and value not in items: items.append(value) return items[:3] def parse_query(raw_text, dialect_hint=None): normalized, dialect = normalize_dialect(raw_text, dialect_hint) text = normalized mentions = detect_product_mentions(text.lower().replace(" ", "")) budget = parse_budget(text) scenes = parse_scenes(text) brands = parse_brands(text) keywords = extract_keywords(text) has_compare = bool(COMPARE_RE.search(text)) conj = ("和" in text or "跟" in text or "比" in text) and len(mentions) >= 2 intent = "recommend" if has_compare or conj: intent = "compare" elif re.search(r"介绍|详情|详细|怎么样|参数|配置|值得买|好不好", text) and len(mentions) == 1: intent = "detail" elif re.search(r"我的|画像|喜好|偏好|记忆|了解我|记得", text) and not mentions: intent = "profile" elif re.search(r"你好|您好|hi|hello|在吗|开始|嗨", text, re.I) and not mentions and not budget: intent = "greet" elif not mentions and not budget and not scenes and not brands and len(text) < 4: intent = "unknown" if re.search(r"再来|还有|更多|换一批|其他", text): intent = "more" return { "raw": raw_text, "normalized": normalized, "dialect": dialect, "mentions": mentions, "budget": budget, "scenes": scenes, "brands": brands, "keywords": keywords, "exclude": parse_excludes(text), "categories": parse_categories(text), "environments": parse_environments(text), "brandTiers": parse_brand_tiers(text), "intent": intent, } # ---------------------------------------------------------------- 记忆库 def _now(): return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _load_memory(): return db.load_memory() def _blank_profile(): return { "createdAt": _now(), "updatedAt": _now(), "searches": [], "views": {}, "clicks": {}, "compares": [], "preferences": {"brand": {}, "scene": {}, "tag": {}, "environment": {}, "brandTier": {}, "category": {}}, "budgetHistory": [], "avoidNew": True, } def get_profile(user_id): with _lock: profile = _load_memory().get(user_id) if not profile: profile = _blank_profile() db.save_memory(user_id, profile) defaults = _blank_profile() for key, default in defaults.items(): profile.setdefault(key, default) prefs = profile["preferences"] for key in defaults["preferences"]: prefs.setdefault(key, {}) return profile def _bump(mapping, key, weight=1, cap=30): if not key: return mapping[key] = min(cap, mapping.get(key, 0) + weight) def record_search(user_id, parsed): with _lock: profile = get_profile(user_id) profile.setdefault("searches", []) profile["searches"].append({ "text": parsed["raw"], "normalized": parsed["normalized"], "dialect": (parsed.get("dialect") or {}).get("name"), "intent": parsed["intent"], "budget": parsed.get("budget"), "scenes": parsed.get("scenes", []), "brands": parsed.get("brands", []), "categories": parsed.get("categories", []), "environments": parsed.get("environments", []), "ts": _now(), }) profile["searches"] = profile["searches"][-60:] prefs = profile.setdefault("preferences", _blank_profile()["preferences"]) for key in ("brand", "scene", "tag", "environment", "brandTier", "category"): prefs.setdefault(key, {}) for b in parsed.get("brands", []): _bump(prefs["brand"], b, 1) for s in parsed.get("scenes", []): _bump(prefs["scene"], s, 1) for c in parsed.get("categories", []): _bump(prefs["category"], c, 1) for e in parsed.get("environments", []): _bump(prefs["environment"], e, 1) for t in parsed.get("brandTiers", []): _bump(prefs["brandTier"], t, 1) if parsed.get("budget"): profile.setdefault("budgetHistory", []).append({ "min": parsed["budget"]["min"], "max": parsed["budget"]["max"], "ts": _now() }) profile["budgetHistory"] = profile["budgetHistory"][-20:] profile["updatedAt"] = _now() db.save_memory(user_id, profile) return profile def record_event(user_id, event_type, product_id): with _lock: profile = get_profile(user_id) product = product_by_id(product_id) if not product: return profile bucket_key = "views" if event_type == "view" else "clicks" if event_type == "click" else "compares" if bucket_key in ("views", "clicks"): bucket = profile.setdefault(bucket_key, {}) item = bucket.setdefault(product_id, {"count": 0, "first": _now()}) item["count"] += 1 item["last"] = _now() else: profile.setdefault("compares", []).append({"ids": product_id, "ts": _now()}) profile["compares"] = profile["compares"][-30:] prefs = profile.setdefault("preferences", _blank_profile()["preferences"]) weight = 2 if event_type == "click" else 1 _bump(prefs.setdefault("brand", {}), product["brand"], weight) for s in product.get("scenes", []): _bump(prefs.setdefault("scene", {}), s, weight) for t in product.get("tags", []): _bump(prefs.setdefault("tag", {}), t, weight) for env in product.get("environments", []): _bump(prefs.setdefault("environment", {}), env, weight) if product.get("brandTier"): _bump(prefs.setdefault("brandTier", {}), product["brandTier"], weight) if product.get("category"): _bump(prefs.setdefault("category", {}), product["category"], weight) profile["updatedAt"] = _now() db.save_memory(user_id, profile) return profile def reset_memory(user_id): with _lock: db.delete_memory(user_id) return True def set_setting(user_id, key, value): with _lock: profile = get_profile(user_id) profile[key] = value profile["updatedAt"] = _now() db.save_memory(user_id, profile) return profile def _avg_budget(profile): hist = profile.get("budgetHistory") or [] if not hist: return None lows = sorted(h["min"] for h in hist) highs = sorted(h["max"] for h in hist) mid = len(hist) // 2 return {"min": lows[mid], "max": highs[mid]} def profile_summary(profile): prefs = profile.get("preferences", {}) brands = [k for k, v in sorted(prefs.get("brand", {}).items(), key=lambda kv: -kv[1])[:2] if v > 0] scenes = [k for k, v in sorted(prefs.get("scene", {}).items(), key=lambda kv: -kv[1])[:3] if v > 0] tags = [k for k, v in sorted(prefs.get("tag", {}).items(), key=lambda kv: -kv[1])[:3] if v > 0] categories = [k for k, v in sorted(prefs.get("category", {}).items(), key=lambda kv: -kv[1])[:3] if v > 0] viewed = set(profile.get("views", {}).keys()) budget = _avg_budget(profile) env_raw = prefs.get("environment", {}) env_total = sum(env_raw.values()) or 1 environments = [ {"key": key, "label": label, "score": round(env_raw.get(key, 0) / env_total * 100)} for key, label in (("indoor", "室内"), ("outdoor", "室外")) ] tier_raw = prefs.get("brandTier", {}) tier_total = sum(tier_raw.values()) or 1 brand_tiers = [ {"key": key, "label": label, "score": round(tier_raw.get(key, 0) / tier_total * 100)} for key, label in BRAND_TIER_LABELS.items() ] parts = [] if categories: parts.append("常看" + "、".join(categories)) if scenes: parts.append("看重" + "、".join(scenes)) if brands: parts.append("偏好" + "、".join(brands) + "品牌") top_env = max(environments, key=lambda item: item["score"]) if top_env["score"] >= 50: parts.append(f"偏{top_env['label']}场景 {top_env['score']}%") top_tier = max(brand_tiers, key=lambda item: item["score"]) if top_tier["score"] >= 40: parts.append(f"偏好{top_tier['label']}品牌 {top_tier['score']}%") if budget: parts.append(f"预算约 {budget['min']}-{budget['max']} 元") summary = ";".join(parts) if parts else "还没有形成明显偏好" return { "summary": summary, "brands": brands, "scenes": scenes, "tags": tags, "categories": categories, "environments": environments, "brandTiers": brand_tiers, "budget": budget, "viewedCount": len(viewed), "searchCount": len(profile.get("searches", [])), "recentSearches": [s["text"] for s in profile.get("searches", [])[-5:]][::-1], "avoidNew": profile.get("avoidNew", True), "untouched": len([p for p in _all_products() if p["id"] not in viewed]), } # ---------------------------------------------------------------- 混合检索引擎 SYNONYMS = { "拍照": ["影像", "相机", "长焦", "夜景", "自拍"], "影像": ["拍照", "相机", "长焦"], "便宜": ["性价比", "千元", "实惠", "降价"], "性价比": ["便宜", "千元", "实惠"], "耐用": ["续航", "电池", "口碑"], "续航": ["电池", "快充", "耐用"], "游戏": ["性能", "散热", "高刷", "电竞"], "老人": ["长辈", "大字体", "续航"], "爸妈": ["长辈", "大字体", "续航"], "学生": ["性价比", "续航", "耐用"], "轻薄": ["轻巧", "小屏", "单手"], "小屏": ["单手", "轻巧", "轻薄"], "折叠": ["fold", "flip", "大屏"], "商务": ["办公", "信号", "旗舰"], "信号": ["商务", "通信", "卫星"], "自拍": ["前置", "美颜", "拍照"], } _INDEX = {"signature": None, "vectors": {}} def _grams(text): cleaned = re.sub(r"[^\u4e00-\u9fa5a-z0-9]", "", (text or "").lower()) return [cleaned[i:i + 2] for i in range(len(cleaned) - 1)] def _doc_text(product): parts = [product["name"], product["brand"], product.get("category", ""), " ".join(product.get("tags", [])), " ".join(product.get("highlights", [])), product.get("audience", ""), " ".join(product.get("scenes", []))] parts += [str(v) for v in (product.get("specs") or {}).values()] return " ".join(parts) def _build_index(): """字符二元组 TF-IDF 向量,商品库变化时自动重建。""" items = _all_products() signature = tuple(p["id"] for p in items) if _INDEX["signature"] == signature: return docs = [_grams(_doc_text(p)) for p in items] df = {} for doc in docs: for g in set(doc): df[g] = df.get(g, 0) + 1 total = max(len(docs), 1) idf = {g: math.log((total + 1) / (count + 1)) + 1 for g, count in df.items()} vectors = {} for product, doc in zip(items, docs): tf = {} for g in doc: tf[g] = tf.get(g, 0) + 1 vec = {g: (1 + math.log(c)) * idf[g] for g, c in tf.items()} norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0 vectors[product["id"]] = {g: v / norm for g, v in vec.items()} _INDEX.update(signature=signature, vectors=vectors) def _query_vector(parsed): text = parsed.get("normalized", "") + " " + " ".join(parsed.get("keywords", [])) for scene in parsed.get("scenes", []): text += " " + " ".join(SYNONYMS.get(scene, [])) for kw in parsed.get("keywords", []): text += " " + " ".join(SYNONYMS.get(kw, [])) tf = {} for g in _grams(text): tf[g] = tf.get(g, 0) + 1 vec = {g: (1 + math.log(c)) for g, c in tf.items()} norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0 return {g: v / norm for g, v in vec.items()} def semantic_score(parsed, product): _build_index() qv = parsed.get("_qvec") if qv is None: qv = _query_vector(parsed) parsed["_qvec"] = qv pv = _INDEX["vectors"].get(product["id"]) if not pv or not qv: return 0.0 return sum(qv.get(g, 0.0) * weight for g, weight in pv.items()) # ---------------------------------------------------------------- 大模型查询理解 UNDERSTAND_CACHE = {} UNDERSTAND_SYSTEM = ( "你是中文购物需求解析器。把用户一句话解析成 JSON,字段固定为:" '{"budgetMin":整数或null,"budgetMax":整数或null,"brands":["小米"],' '"needs":["拍照","轻薄"],"exclude":["曲面屏"],"keywords":["小尺寸","给爸妈"]}。' "needs 只能取:拍照、游戏、续航、小屏、大屏、轻薄、性价比、折叠、商务、自拍、系统、信号。" "不确定的字段给空数组或 null;只输出 JSON,不要解释。" ) def _chat_json(system, user, timeout=12): key, base, model = _llm_conf() if not key: return None import urllib.request payload = { "model": model, "temperature": 0.1, "max_tokens": 300, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], } req = urllib.request.Request( base + "/chat/completions", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) return json.loads(data["choices"][0]["message"]["content"]) except Exception: return None def llm_understand(message): if not llm_enabled(): return None message = (message or "").strip() if not message: return None hit = UNDERSTAND_CACHE.get(message) if hit and time.time() - hit[0] < 600: return hit[1] info = _chat_json(UNDERSTAND_SYSTEM, message) if isinstance(info, dict): UNDERSTAND_CACHE[message] = (time.time(), info) return info return None def _enrich_parsed(parsed): """规则解析不够细时,用大模型补全预算/场景/否定条件/关键词。""" if parsed["intent"] not in ("recommend", "detail", "compare") or parsed.get("mentions"): return parsed if parsed.get("budget") and parsed.get("scenes"): return parsed info = llm_understand(parsed.get("raw", "")) if not info: return parsed if not parsed.get("budget"): lo = info.get("budgetMin") if isinstance(info.get("budgetMin"), int) else None hi = info.get("budgetMax") if isinstance(info.get("budgetMax"), int) else None if lo or hi: lo = lo or 0 hi = hi or 99999 label = f"{lo}-{hi} 元" if lo else f"{hi} 元以内" parsed["budget"] = {"min": lo, "max": hi, "label": label, "type": "llm"} for brand in info.get("brands") or []: if isinstance(brand, str) and brand and brand not in parsed["brands"]: parsed["brands"].append(brand) for need in info.get("needs") or []: if isinstance(need, str) and need in SCENES and need not in parsed["scenes"]: parsed["scenes"].append(need) for word in (info.get("needs") or []) + (info.get("keywords") or []): if isinstance(word, str) and len(word) >= 2 and word not in parsed["keywords"]: parsed["keywords"].append(word) for word in info.get("exclude") or []: if isinstance(word, str) and word and word not in parsed["exclude"]: parsed["exclude"].append(word) parsed["llmParsed"] = True return parsed # ---------------------------------------------------------------- 联网商品补充 MODEL_RE = re.compile( r"(?:小米|红米|redmi|苹果|iphone|ipone|华为|荣耀|oppo|vivo|一加|三星|魅族|realme|iqoo|努比亚|moto)" r"\s*(?:mate|pura|nova|magic|find|turbo|flip|fold|k|note|x|数字)?\s*\d{1,3}\s*(?:pro|plus|max|ultra|mini|se|gt)?", re.I, ) FRESH_RE = re.compile(r"最新|新款|新机|刚发布|刚出|今年|上市|2025|2026") ALL_ALIASES = {a.lower() for values in EXTRA_ALIASES.values() for a in values} ALL_ALIASES |= {p["name"].lower() for p in PRODUCTS} def unknown_model(text): compact = re.sub(r"\s", "", (text or "").lower()) for match in MODEL_RE.finditer(compact): phrase = match.group(0) if any(alias in phrase or phrase in alias for alias in ALL_ALIASES): continue return phrase return "" def run_web_search(parsed): """只在“本地库没有的机型 / 问最新款”时联网,避免每次对话都外呼。""" if not websearch.enabled() or parsed["intent"] not in ("recommend", "detail", "compare"): return [] compact = re.sub(r"\s", "", parsed.get("normalized", "").lower()) model = unknown_model(compact) if model: query = f"{model} 价格 参数 评测" elif FRESH_RE.search(compact): query = f"{parsed.get('raw', '')} 价格 参数 最新" else: return [] products = websearch.lookup(query) if products: parsed["webIds"] = [p["id"] for p in products] parsed["webQuery"] = query return products # ---------------------------------------------------------------- 打分与推荐 def score_product(product, parsed, profile, feed_mode=False): raw = 0.0 reasons = [] rating = product.get("rating") or 92 raw += (rating - 95) * 5.5 for brand in parsed.get("brands", []): if product["brand"] == brand: raw += 26 reasons.append(f"你点名要看{product['brand']}") matched_scenes = [s for s in parsed.get("scenes", []) if s in product["scenes"]] raw += len(matched_scenes) * 23 if matched_scenes: reasons.append("符合你的" + "、".join(matched_scenes) + "需求") for kw in parsed.get("keywords", []): hay = (product["name"] + "".join(product["tags"]) + "".join(product["highlights"]) + product["audience"]) if kw and kw in hay: raw += 6 if any(v in product["name"] for v in _kw_variants(kw)): raw += 10 budget = parsed.get("budget") if budget: if budget["min"] <= product["price"] <= budget["max"]: raw += 20 reasons.append(f"落在 {budget['label']} 预算内") elif product["price"] > budget["max"]: over = (product["price"] / max(budget["max"], 1) - 1) raw -= min(55, over * 90) if over < 0.12: reasons.append("略超预算但与需求最接近") else: raw -= 8 prefs = profile.get("preferences", {}) mem_brand = prefs.get("brand", {}).get(product["brand"], 0) if mem_brand: raw += min(18, mem_brand * 3) if mem_brand >= 3: reasons.append(f"你历史上多次关注{product['brand']}") mem_scene = sum(prefs.get("scene", {}).get(s, 0) for s in product["scenes"]) if mem_scene: raw += min(20, mem_scene * 2.2) top_scene = max(product["scenes"], key=lambda s: prefs.get("scene", {}).get(s, 0)) if prefs.get("scene", {}).get(top_scene, 0) >= 2: reasons.append(f"记得你偏爱{top_scene}") mem_tag = sum(prefs.get("tag", {}).get(t, 0) for t in product["tags"]) if mem_tag: raw += min(14, mem_tag * 1.8) if product.get("category") in parsed.get("categories", []): raw += 6 envs = parsed.get("environments", []) if envs and any(env in product.get("environments", []) for env in envs): raw += 9 mem_env = sum(prefs.get("environment", {}).get(env, 0) for env in product.get("environments", [])) if mem_env: raw += min(16, mem_env * 2) tier = product.get("brandTier") if tier: mem_tier = prefs.get("brandTier", {}).get(tier, 0) if mem_tier: raw += min(12, mem_tier * 2) if mem_tier >= 3: reasons.append(f"你偏好{BRAND_TIER_LABELS.get(tier, tier)}品牌") hay = product["name"] + "".join(product["tags"]) + "".join(product["highlights"]) + product["audience"] for banned in parsed.get("exclude", []): if banned and banned in hay: raw -= 80 raw += semantic_score(parsed, product) * 18 if feed_mode and profile.get("avoidNew", True): viewed = profile.get("views", {}) if viewed and product["id"] not in viewed: raw -= 18 if not reasons: if product.get("rating"): reasons.append(f"好评率 {product['rating']}%,{fmt_sales(product['sales'])} 人好评") else: reasons.append("联网检索到的在售新机型,价格仅供参考") return raw, reasons[:3] def _apply_filters(parsed, relax=False): budget = parsed.get("budget") brands = parsed.get("brands", []) result = _all_products() categories = parsed.get("categories", []) if categories: filtered = [p for p in result if p.get("category") in categories] if filtered: result = filtered tiers = parsed.get("brandTiers", []) if tiers: filtered = [p for p in result if p.get("brandTier") in tiers] if filtered: result = filtered envs = parsed.get("environments", []) if envs: filtered = [p for p in result if any(e in p.get("environments", []) for e in envs)] if filtered: result = filtered if brands: filtered = [p for p in result if p["brand"] in brands] if filtered: result = filtered # 具体品类词(如"扫地机器人""吹风机")命中商品名时,先收窄到这些型号 # 再叠加预算,避免预算把同类型商品全过滤后换成别的品类 keywords = [k for k in parsed.get("keywords", []) if len(k) >= 3] if keywords: hits = [p for p in result if any(v in p["name"] for k in keywords for v in _kw_variants(k))] if hits: result = hits if budget and not relax: upper = budget["max"] * 1.28 filtered = [p for p in result if p["price"] <= upper] if filtered: result = filtered excludes = parsed.get("exclude", []) if excludes: filtered = [p for p in result if not any(e and e in p["name"] + "".join(p.get("tags", []) + p.get("highlights", [])) for e in excludes)] if filtered: result = filtered return result def _spec_items(product): specs = product.get("specs") if isinstance(specs, dict) and specs: return [{"label": key, "value": value} for key, value in specs.items()] return [{"label": label, "value": product.get(key, "暂无")} for key, label in SPEC_KEYS] # 商品图:按具体型号映射到对应品类图,联网商品按品类兜底 IMAGE_BY_ID = { "bose-ultra": "headphones", "shokz-openrun": "earbuds", "mi-band9": "smartwatch", "haier-fridge": "fridge", "dyson-hd15": "hairdryer", "roborock-s8": "robot_vacuum", "gree-ac": "air_conditioner", "midea-ac": "air_conditioner", "joyoung-soy": "blender", "sk2-facial": "serum", "lancome-serum": "serum", "proya-serum": "serum", "huaxizi-powder": "makeup", "perfect-diary": "makeup", "arcteryx-jacket": "jacket", "anta-shoes": "shoes", "uniqlo-fleece": "clothing", "neiwai-bra": "clothing", "linshi-sofa": "sofa", "yuanshi-table": "desk", "ikea-desk": "desk", "guanxia-candle": "candle", "squirrel-nuts": "nuts", "wuchang-rice": "rice", "yili-milk": "milk", "genki-soda": "soda", "baixiang-noodle": "noodles", } IMAGE_BY_CATEGORY = { "手机": "phone", "笔记本": "laptop", "平板": "tablet", "耳机": "earbuds", "智能穿戴": "smartwatch", "家电": "appliance", "美妆": "serum", "食品": "nuts", "服饰": "clothing", "家居": "sofa", } def product_image(product): slug = IMAGE_BY_ID.get(product["id"]) or IMAGE_BY_CATEGORY.get(product.get("category", "")) return f"/img/products/{slug}.png" if slug else "" def product_card(product, profile=None, match=0, reasons=None): rating = product.get("rating") sales = product.get("sales") return { "id": product["id"], "brand": product["brand"], "category": product.get("category", ""), "image": product_image(product), "name": product["name"], "price": product["price"], "rating": rating, "ratingText": f"{rating}%" if rating else "暂无", "sales": sales, "salesText": fmt_sales(sales) if sales else "暂无", "tags": product["tags"], "highlights": product["highlights"], "audience": product["audience"], "specs": _spec_items(product), "color": product.get("color", "#ff6900"), "color2": product.get("color2", "#ffb26b"), "match": match, "reasons": reasons or [], "web": product.get("origin") == "web", "note": product.get("note", ""), "sourceUrl": product.get("sourceUrl", ""), } def recommend(parsed, profile, limit=3, feed_mode=False, exclude=None): candidates = _apply_filters(parsed) if not candidates: candidates = _apply_filters(parsed, relax=True) scored = [] for p in candidates: if exclude and p["id"] in exclude: continue raw, reasons = score_product(p, parsed, profile, feed_mode=feed_mode) scored.append((raw, p, reasons)) scored.sort(key=lambda x: -x[0]) cards = [] for raw, p, reasons in scored[:limit]: match = int(max(35, min(99, 50 + raw * 0.45))) cards.append(product_card(p, profile, match, reasons)) return cards def recommend_feed(user_id, limit=6): profile = get_profile(user_id) parsed = {"brands": [], "scenes": [], "keywords": [], "budget": None, "mentions": [], "intent": "feed"} prefs = profile.get("preferences", {}) # 依据记忆画像构造软需求 top_scenes = [k for k, _ in sorted(prefs.get("scene", {}).items(), key=lambda kv: -kv[1])[:2] if _ > 0] top_brands = [k for k, _ in sorted(prefs.get("brand", {}).items(), key=lambda kv: -kv[1])[:1] if _ > 0] parsed["scenes"] = top_scenes parsed["brands"] = [] # 推荐流不硬性限制品牌,只靠打分 budget = _avg_budget(profile) if budget: parsed["budget"] = {"min": budget["min"], "max": budget["max"], "label": f"{budget['min']}-{budget['max']}元", "type": "range"} cards = recommend(parsed, profile, limit=limit, feed_mode=True) summary = profile_summary(profile) plan = build_purchase_plan(cards, parsed, profile, summary) if cards else None note = "" if summary["searchCount"] or summary["viewedCount"]: bits = [] if top_scenes: bits.append("你偏爱" + "、".join(top_scenes)) if top_brands: bits.append("常看" + "、".join(top_brands)) if budget: bits.append(f"预算集中在 {budget['min']}-{budget['max']} 元") note = ",".join(bits) + ",已按这些偏好排序" if summary["avoidNew"] and summary["viewedCount"]: note += f",并避开了 {summary['untouched']} 款你从没看过的商品" return {"products": cards, "profile": summary, "note": note, "plan": plan, "total": len(PRODUCTS)} # ---------------------------------------------------------------- 对比表 def _best_index(values, mode): if mode == "min": return values.index(min(values)) if mode == "max": return values.index(max(values)) return None def _spec_labels(items): labels = [] for product in items: for item in _spec_items(product): if item["label"] not in labels: labels.append(item["label"]) return labels def build_compare(ids, profile=None, parsed=None): items = [p for p in (product_by_id(i) for i in ids) if p][:3] if not items: return None rows = [ {"key": "price", "label": "价格", "group": "core", "best": "min", "values": [f"¥{p['price']}" for p in items], "raw": [p["price"] for p in items]}, {"key": "rating", "label": "好评率", "group": "core", "best": "max", "values": [f"{p['rating']}%" if p.get("rating") else "暂无" for p in items], "raw": [p.get("rating") or 0 for p in items]}, {"key": "sales", "label": "销量", "group": "core", "best": "max", "values": [f"{fmt_sales(p['sales'])}台" if p.get("sales") else "暂无" for p in items], "raw": [p.get("sales") or 0 for p in items]}, ] for label in _spec_labels(items)[:7]: rows.append({ "key": "spec:" + label, "label": label, "group": "spec", "best": None, "values": [dict((s["label"], s["value"]) for s in _spec_items(p)).get(label, "暂无") for p in items], "raw": [dict((s["label"], s["value"]) for s in _spec_items(p)).get(label, "暂无") for p in items], }) best = {} for row in rows: if row["best"]: idx = _best_index(row["raw"], row["best"]) if idx is not None: best[row["key"]] = idx advice = [] cheapest = min(items, key=lambda p: p["price"]) advice.append({"title": "价格最低", "text": f"{cheapest['name']},¥{cheapest['price']},比最高价省 ¥{max(p['price'] for p in items) - cheapest['price']}"}) rated = [p for p in items if p.get("rating")] if rated: top_rated = max(rated, key=lambda p: p["rating"]) advice.append({"title": "口碑最好", "text": f"{top_rated['name']},好评率 {top_rated['rating']}%"}) else: advice.append({"title": "口碑最好", "text": "联网商品暂无好评率数据,建议下单前看下商品页评价"}) sellers = [p for p in items if p.get("sales")] if sellers: best_seller = max(sellers, key=lambda p: p["sales"]) advice.append({"title": "销量最高", "text": f"{best_seller['name']},累计 {fmt_sales(best_seller['sales'])}台"}) if parsed and parsed.get("scenes"): scenes = parsed["scenes"] fit = max(items, key=lambda p: len([s for s in scenes if s in p.get("scenes", [])]) * 2 + (p.get("rating") or 90) / 100) advice.append({"title": f"最契合你的{'、'.join(scenes)}需求", "text": f"{fit['name']}:{fit['highlights'][0]}"}) return { "products": [product_card(p, profile) for p in items], "rows": rows, "best": best, "advice": advice, "ids": [p["id"] for p in items], } # ---------------------------------------------------------------- 可解释购买方案 CATEGORY_RISK = { "家电": "大家电涉及配送安装,确认是否含上门安装、旧机回收,并保留发票", "美妆": "美妆开封后通常不支持退换,认准官方渠道和防伪标识", "食品": "食品注意保质期与是否临期,优先官方旗舰店", "服饰": "服饰注意尺码表和七天无理由退换", "手机": "手机确认国行版本、保修年限,优先支持官方售后的渠道", "笔记本": "笔记本确认保修年限与是否支持上门/寄修", "平板": "平板确认保修与是否含笔/键盘配件", "耳机": "耳机注意是否支持试用退换与保修时长", "智能穿戴": "穿戴设备注意表带尺寸与保修范围", "家居": "家居注意送货安装、尺寸是否进得了门", } def _env_labels(keys): labels = {"indoor": "室内", "outdoor": "室外"} return "、".join(labels.get(k, k) for k in keys or []) def _after_sales_risk(product): """按品牌档位、来源、品类、好评率给出售后风险与规避建议。""" level = "低" items = [] if product.get("origin") == "web": level = "中" items.append("联网检索款,下单前确认店铺是否官方授权、是否支持全国联保") if product.get("brandTier") == "niche": level = "中" items.append("小众品牌服务网点少,维修和配件等待时间可能更长") if product.get("brandTier") == "luxury": items.append("客单价偏高,建议加购延保/意外险并保留发票") note = CATEGORY_RISK.get(product.get("category", "")) if product.get("category") == "家电" and any( k in product.get("name", "") for k in ("吹风", "破壁", "豆浆", "电饭煲", "空气炸", "咖啡", "加湿", "风扇")): note = "小家电注意保修时长与是否支持只换不修,优先官方旗舰店" if note: items.append(note) rating = product.get("rating") or 0 if rating and rating < 92: level = "中" items.append(f"好评率 {rating}%,建议先翻差评确认共性问题") if (product.get("price") or 0) >= 8000: items.append("单价较高,优先选支持价保和七天无理由的渠道") return {"level": level, "items": items[:3]} def _fit_reasons(card, raw, parsed, summary): """把预算、场景、习惯、口碑组合成“为什么适合你”。""" head = [] budget = parsed.get("budget") if budget and budget.get("max"): if budget["min"] <= raw["price"] <= budget["max"]: left = budget["max"] - raw["price"] txt = f"¥{raw['price']} 落在你 {budget['label']} 的预算内" if budget.get("type") != "min" and left >= 100: txt += f",还留 ¥{left} 余量给配件/延保" elif 0 < left < 100: txt += ",几乎用满预算" head.append(txt) elif raw["price"] < budget["min"]: head.append(f"¥{raw['price']} 低于你 {budget['label']} 的预算下限,预算比较宽裕") envs = parsed.get("environments") or [] hit_env = [e for e in envs if e in (raw.get("environments") or [])] if hit_env: head.append(f"覆盖你说的{_env_labels(hit_env)}使用场景") scenes = parsed.get("scenes") or [] hit_scene = [s for s in scenes if s in (raw.get("scenes") or []) or s in (raw.get("tags") or [])] if hit_scene: head.append("符合你看重的" + "、".join(hit_scene[:2])) top_env = max(summary["environments"], key=lambda x: x["score"]) if top_env["score"] >= 50 and top_env["key"] in (raw.get("environments") or []) and not hit_env: head.append(f"你 {top_env['score']}% 的浏览偏{top_env['label']},这款也适配") top_tier = max(summary["brandTiers"], key=lambda x: x["score"]) if top_tier["score"] >= 40 and raw.get("brandTier") == top_tier["key"]: head.append(f"是你偏好的{top_tier['label']}品牌档位") if raw.get("rating") and raw.get("sales"): head.append(f"好评率 {raw['rating']}%、销量 {fmt_sales(raw['sales'])},口碑经过市场验证") elif raw.get("origin") == "web": head.append("本地库没有同款,这条是联网查到的在售信息") # 打分理由里常含相同关键词(如“通勤”“好评”),避免与上面的理由重复 overlap = set(hit_scene) | set(hit_env) | set(scenes) | set(envs) head_text = "".join(head) tail = [] for r in card.get("reasons") or []: if not r or any(k in r for k in overlap): continue if "好评" in r and "好评" in head_text: continue if "销量" in r and "销量" in head_text: continue if "预算" in r and "预算" in head_text: continue tail.append(r) seen, out = set(), [] for r in head + tail: if r not in seen: seen.add(r) out.append(r) return out[:4] def _plan_alternatives(cards, primary): others = [c for c in cards if c["id"] != primary["id"]] if not others: return [] picks = [] cheaper = [c for c in others if c["price"] < primary["price"]] if cheaper: pick = max(cheaper, key=lambda c: c["price"]) picks.append((pick, f"比主推省 ¥{primary['price'] - pick['price']}")) rated = [c for c in others if c.get("rating")] if rated: best = max(rated, key=lambda c: c["rating"]) if best["id"] not in [p[0]["id"] for p in picks]: picks.append((best, f"口碑更稳,好评 {best['rating']}%")) sold = [c for c in others if c.get("sales")] if sold: top = max(sold, key=lambda c: c["sales"]) if top["id"] not in [p[0]["id"] for p in picks]: picks.append((top, f"买的人更多,销量 {top['salesText']}")) if not picks: picks.append((others[0], "同价位备选,可以一起对比")) return [ {"id": c["id"], "name": c["name"], "price": c["price"], "role": role, "image": c.get("image", "")} for c, role in picks[:2] ] def build_purchase_plan(cards, parsed, profile, summary=None): """生成可解释购买方案:预算、场景、口碑、售后风险、推荐理由与备选。""" if not cards: return None summary = summary or profile_summary(profile) primary = cards[0] raw = product_by_id(primary["id"]) or {} budget = parsed.get("budget") or summary.get("budget") alt = _plan_alternatives(cards, primary) if budget and budget.get("max"): inside = [c for c in cards if budget["min"] <= c["price"] <= budget["max"]] below = [c for c in cards if c["price"] < budget["min"]] over = [c for c in cards if c["price"] > budget["max"]] if inside: text = f"预算 {budget['label']}:{len(cards)} 款里 {len(inside)} 款在预算内" elif below and not over: text = f"预算 {budget['label']}:这几款都低于预算下限,预算比较宽裕" elif over and not below: text = f"预算 {budget['label']}:这几款略超上限" else: text = f"预算 {budget['label']}:有低于下限的,也有略超上限的" if over: text += f";略超的是 {over[0]['name']} ¥{over[0]['price']}" if primary["price"] < budget["min"]: text += f";主推 ¥{primary['price']},比下限还低 ¥{budget['min'] - primary['price']}" elif primary["price"] > budget["max"]: text += f";主推超预算 ¥{primary['price'] - budget['max']}" text += ",可以先看备选" if alt else ",同类型里没有更便宜的,建议放宽预算或等促销" elif budget.get("type") != "min": left = budget["max"] - primary["price"] text += f";主推还剩 ¥{left} 余量" if left >= 100 else f";主推几乎用满预算(仅余 ¥{left})" else: text = "你还没给预算,我先按匹配度和口碑排序;补一句预算可以更精准" envs = parsed.get("environments") or [e["key"] for e in summary["environments"] if e["score"] >= 50] scenes = parsed.get("scenes") or summary.get("scenes", []) scenario_bits = [] if envs: scenario_bits.append(f"{_env_labels(envs)}使用") if scenes: scenario_bits.append("看重" + "、".join(scenes[:3])) if parsed.get("categories"): scenario_bits.append("品类锁定" + "、".join(parsed["categories"])) scenario_text = ",".join(scenario_bits) if scenario_bits else "按你历史偏好推断" why = _fit_reasons(primary, raw, parsed, summary) risk = _after_sales_risk(raw) review_text = ( f"好评率 {primary['ratingText']},销量 {primary['salesText']}" if primary.get("rating") else "联网款暂无评价样本,建议看商品页近 30 天评价与差评关键词" ) verdict = f"如果只买一款,我建议 {primary['name']}" if alt: verdict += f";备选 {alt[0]['name']}({alt[0]['role']})" if len(alt) > 1: verdict += f",也可看 {alt[1]['name']}({alt[1]['role']})" verdict += "。" return { "title": ("、".join(parsed.get("categories") or []) or (raw.get("category") or "商品")) + "购买方案", "budget": {"label": budget["label"] if budget else "未设置", "text": text}, "scenario": {"text": scenario_text}, "primary": { "id": primary["id"], "name": primary["name"], "price": primary["price"], "ratingText": primary["ratingText"], "salesText": primary["salesText"], "image": primary.get("image", ""), "why": why, }, "alternatives": alt, "reviews": {"text": review_text, "web": primary.get("web", False)}, "risk": risk, "verdict": verdict, "steps": [ "优先官方旗舰店/自营,确认发票、保修与是否国行", "下单前比价并确认价保规则,避免刚买就降价", "收货 7 天内集中试用,保留包装以便退换", ], } # ---------------------------------------------------------------- 话术生成 def _fmt_price(p): return f"¥{p['price']}" def _card_sentence(card): return f"{card['name']}({_fmt_price(card)},好评 {card['ratingText']},销量 {card['salesText']})" def build_reply(parsed, cards, compare_data, profile, extra=None): dialect = parsed.get("dialect") prefix = f"(听出你说的是{dialect['name']},已经自动转成普通话理解啦)" if dialect else "" summary = profile_summary(profile) intent = parsed["intent"] extra = extra or {} if intent == "greet" or (intent == "unknown" and not cards): return ( f"{prefix}你好,我是小函,你的智能导购助手。手机、电脑、耳机、家电、美妆、食品都能挑," "告诉我想买什么、预算多少、室内还是室外用,我就能帮你选;" "你也可以直接说“小米 15 和 iPhone 16 哪个好”,我会给你一张价格/好评/销量的对比表。" f"({summary['summary']})" ), ["想买拍照好的手机", "通勤用的降噪耳机", "家里用的扫地机器人"] if intent == "profile": recent = "、".join(summary["recentSearches"]) if summary["recentSearches"] else "暂无" text = ( f"{prefix}我对你的记忆是这样的:{summary['summary']}。" f"你一共搜索过 {summary['searchCount']} 次,看过 {summary['viewedCount']} 款商品,最近在看:{recent}。" + ("当前我还会避开你从没看过的商品。" if summary["avoidNew"] else "当前我允许推荐你没看过的新商品。") ) return text, ["给我推荐几款", "帮我对比一下", "清空我的记忆"] if intent == "compare": if not compare_data: return ( f"{prefix}我还没听清你要比哪两款,可以说“小米 15 和 iPhone 16 哪个好”," "或者直接告诉我两个品牌,比如“小米和苹果对比一下”。" ), ["小米 15 对比 iPhone 16", "小米和苹果哪个好", "对比前两款"] names = [p["name"] for p in compare_data["products"]] cheapest = compare_data["advice"][0]["text"] rated = compare_data["advice"][1]["text"] sold = compare_data["advice"][2]["text"] verdict = compare_data["advice"][3] if len(compare_data["advice"]) > 3 else None text = ( f"{prefix}我把 {' / '.join(names)} 放在一起比了,价格、好评率、销量做成表格放在右侧。\n" f"· 价格:{cheapest}\n" f"· 好评:{rated}\n" f"· 销量:{sold}\n" ) if verdict: text += f"· 贴合你的需求:{verdict['text']}\n" text += "结论我写在表格下方,想看哪款的细节也可以直接问我。" text += "\n\n是否需要为您提供一份表格?" return text, ["需要,给我一份表格", "再看详细参数", "换成小米和苹果对比", "直接买哪款更稳"] if intent == "detail" and cards: card = cards[0] p = product_by_id(card["id"]) lines = [ f"{prefix}「{p['name']}」目前 {_fmt_price(p)},好评率 {card['ratingText']},销量 {card['salesText']}。", "核心参数:" + " · ".join(f"{s['label']} {s['value']}" for s in card["specs"]), "亮点:" + ";".join(p["highlights"]), f"适合:{p['audience']}。", ] if p.get("origin") == "web": lines.append(f"说明:这款是联网检索到的在售机型,{p.get('note', '价格仅供参考')}。") return "\n".join(lines), ["对比同价位机型", f"对比{p['name']}和 iPhone 16", "还有更便宜的吗"] plan = extra.get("plan") if cards and plan: primary = plan["primary"] lines = [ f"{prefix}我按「{_describe_needs(parsed)}」筛了一遍,给你一份能直接执行的购买方案:", "", f"主推:{primary['name']}(¥{primary['price']},好评 {primary['ratingText']},销量 {primary['salesText']})", "为什么适合你:", ] lines += [f"· {r}" for r in primary["why"]] lines.append(f"评价:{plan['reviews']['text']}") risk_items = ";".join(plan["risk"]["items"]) or "常规售后,按品类保修政策执行" lines.append(f"售后风险({plan['risk']['level']}):{risk_items}") if plan["alternatives"]: alt_text = ";".join(f"{a['name']} ¥{a['price']}({a['role']})" for a in plan["alternatives"]) lines.append(f"备选:{alt_text}") lines.append(f"结论:{plan['verdict']}") lines.append("下单前三步:" + " ".join(f"{i}. {s}" for i, s in enumerate(plan["steps"], 1))) if extra.get("relaxed"): lines.append("严格符合条件的选择不多,我稍微放宽了预算范围,这几款最接近。") web_cards = [c for c in cards if c.get("web")] if web_cards: lines.append(f"其中 {'、'.join(c['name'] for c in web_cards)} 是联网检索到的参考价,实际以下单页面为准。") quicks = [f"介绍一下{primary['name']}", "对比这 %d 款" % len(cards), "再便宜一点"] return "\n".join(lines), quicks if cards: lines = [f"{prefix}我按「{_describe_needs(parsed)}」筛了一遍,这 {len(cards)} 款最贴:"] for idx, card in enumerate(cards, 1): line = f"{idx}. {_card_sentence(card)}" if card["reasons"]: line += f" —— {card['reasons'][0]}" lines.append(line) if summary["searchCount"] or summary["viewedCount"]: note_bits = [] if summary["scenes"]: note_bits.append("你偏爱" + "、".join(summary["scenes"])) if summary["brands"]: note_bits.append("常看" + "、".join(summary["brands"])) if note_bits: lines.append("顺带说一句," + "、".join(note_bits) + ",所以我把这类商品的权重调高了。") if extra.get("relaxed"): lines.append("严格符合条件的选择不多,我稍微放宽了预算范围,这几款最接近。") web_cards = [c for c in cards if c.get("web")] if web_cards: lines.append(f"其中 {'、'.join(c['name'] for c in web_cards)} 是联网检索到的参考价,实际以下单页面为准。") lines.append("要不要我把这几款做成对比表?") quicks = ["对比这 %d 款" % len(cards), "再便宜一点", "只看拍照强的"] return "\n".join(lines), quicks return ( f"{prefix}没找到完全符合的商品。换个说法试试:告诉我品类(手机 / 笔记本 / 耳机 / 家电 / 美妆…)、" "预算(比如 3000-4000 元)、使用场景(室内 / 室外)或最看重的点(拍照 / 游戏 / 降噪 / 轻便)。" ), ["3000-4000 元拍照好的手机", "通勤用的降噪耳机", "家里用的扫地机器人"] def _describe_needs(parsed): parts = [] if parsed.get("categories"): parts.append("、".join(parsed["categories"])) if parsed.get("budget"): parts.append(parsed["budget"]["label"]) if parsed.get("brands"): parts.append("、".join(parsed["brands"])) if parsed.get("scenes"): parts.append("看重" + "、".join(parsed["scenes"])) if parsed.get("environments"): labels = {"indoor": "室内", "outdoor": "室外"} parts.append("、".join(labels.get(e, e) for e in parsed["environments"]) + "用") if parsed.get("mentions"): names = "、".join(product_by_id(i)["name"] for i in parsed["mentions"]) parts.append(f"提到{names}") elif parsed.get("webIds"): names = "、".join(product_by_id(i)["name"] for i in parsed["webIds"][:2]) parts.append(f"提到{names}") return " / ".join(parts) if parts else "你的需求" # ---------------------------------------------------------------- 对比表格导出 EXPORT_DIR = os.path.join(BASE_DIR, "exports") EXPORT_TTL = 1800 # 30 分钟内可继续导出上一次对比 _compare_cache = {} NEGATIVE_WORDS = ("不需要", "不用", "不要", "别", "算了", "暂时不", "先不") AFFIRM_WORDS = ("需要", "要", "好的", "好", "可以", "行", "是的", "是", "嗯", "对", "没问题", "来一份", "给我一份", "要一份", "生成", "导出", "下载", "提供", "麻烦") def _remember_compare(user_id, model, ids=None): """记住最近一次对比,用户说“需要表格”时据此生成文件。""" _compare_cache[user_id] = {"model": model, "ids": ids or [], "ts": time.time()} if len(_compare_cache) > 200: for key in sorted(_compare_cache, key=lambda k: _compare_cache[k]["ts"])[:50]: _compare_cache.pop(key, None) def _is_export_request(text): """判断用户这句是不是“要表格/要下载”。""" norm = re.sub(r"[\s,。!?、,.!?~~::;;\"'“”‘’]+", "", text or "") if not norm or len(norm) > 20: return False if any(w in norm for w in NEGATIVE_WORDS): return False if "表格" in norm: return any(w in norm for w in ("需要", "要", "给我", "来", "下载", "导出", "生成", "提供", "发")) return any(norm == w or norm in (w + "的", w + "吧", w + "啊", w + "呀") for w in AFFIRM_WORDS) def _compare_export_model(compare_data): products = compare_data.get("products") or [] headers = ["对比维度"] + [p["name"] for p in products] rows = [[r["label"]] + list(r["values"]) for r in compare_data.get("rows", [])] notes = [f"{a['title']}:{a['text']}" for a in compare_data.get("advice", [])] return { "title": "商品对比表", "headers": headers, "rows": rows, "notes": notes, "file_names": [p["name"] for p in products], } def _export_reply(user_id): cached = _compare_cache.get(user_id) if not cached: return None if time.time() - cached["ts"] > EXPORT_TTL: _compare_cache.pop(user_id, None) return None model = cached["model"] filename = xlsx_export.new_filename() path = os.path.join(EXPORT_DIR, filename) try: xlsx_export.write_xlsx(model, path) except Exception: return None names = model.get("file_names") or [n for n in (model.get("headers") or [])[1:] if n] names = [re.sub(r"(原商品)$", "", n).strip() for n in names if n] display = xlsx_export.safe_filename( "商品对比_" + "_".join(names[:3]) + ".xlsx" if names else "商品对比表.xlsx" ) url = f"/download/{filename}?name={urllib.parse.quote(display, safe='')}" reply = "\n".join([ "**表格已生成**", "", f"- 文件:{display}(Excel .xlsx,可直接用 Microsoft Excel / WPS 打开)", f"- 内容:{model.get('title', '商品对比表')},共 {len(model.get('rows', []))} 项对比,附选购结论", f"- 下载链接:[点击下载 {display}]({url})", "", "> 点击链接会把文件下载到你的电脑,双击即可在本地用 Excel / WPS 打开。", ]) return { "reply": reply, "markdown": True, "download": {"url": url, "name": display, "kind": "Excel"}, "narrator": "template", "intent": "export", "dialect": None, "parsed": {"normalized": "", "budget": None, "brands": [], "scenes": [], "mentions": []}, "products": [], "compare": None, "plan": None, "quickReplies": ["再看看对比表", "继续帮我挑一款"], "profile": profile_summary(get_profile(user_id)), } # ---------------------------------------------------------------- 链接导购 LINK_HINTS = [ ("手机", ["手机", "iphone", "ipone", "redmi", "mate", "pura", "nova", "galaxy", "5g", "折叠屏"]), ("笔记本", ["笔记本", "笔电", "macbook", "游戏本", "轻薄本", "酷睿", "锐龙", "rtx", "thinkpad"]), ("平板", ["平板", "ipad", "matepad"]), ("耳机", ["耳机", "airpods", "降噪耳", "蓝牙耳机"]), ("智能穿戴", ["手表", "手环", "watch", "穿戴"]), ("家电", ["空调", "冰箱", "洗衣机", "扫地机", "扫地机器人", "吹风机", "豆浆机", "破壁机", "净化器"]), ("美妆", ["精华", "口红", "蜜粉", "面霜", "护肤", "神仙水", "粉底"]), ("食品", ["坚果", "牛奶", "大米", "泡面", "饮料", "零食"]), ("服饰", ["外套", "冲锋衣", "跑鞋", "鞋", "内衣", "羽绒服"]), ("家居", ["沙发", "餐桌", "书桌", "香薰", "蜡烛", "床垫"]), ] def _norm_key(text): return re.sub(r"[\s/·\-_()()\[\]【】,,.。::|]+", "", (text or "").lower()) def _match_link_product(title): """把链接标题匹配到站内同款,用于补全品牌、参数与参考价。""" norm = _norm_key(title) if len(norm) < 3: return None best, best_len = None, 0 for p in _all_products(): names = [_norm_key(p.get("name"))] + [_norm_key(a) for a in EXTRA_ALIASES.get(p["id"], [])] for cand in names: if len(cand) >= 3 and cand in norm and len(cand) > best_len: best, best_len = p, len(cand) return best def _link_category(title, linked): if linked and linked.get("category"): return linked["category"] cats = parse_categories(title) if cats: return cats[0] low = (title or "").lower() for category, words in LINK_HINTS: if any(w in low for w in words): return category return "" def _title_specs(title): """标题里能直接读到的硬参数,读不到就交给调用方标注【暂无公开参数】。""" bits = [] m = re.search(r"(\d+(?:\.\d+)?\s*(?:GB|TB|G|T))(?![a-z])", title or "", re.I) if m: bits.append(m.group(1).upper().replace(" ", "")) m = re.search(r"(\d+(?:\.\d+)?\s*英寸)", title or "") if m: bits.append(m.group(1).replace(" ", "")) if "5g" in (title or "").lower(): bits.append("5G") return " / ".join(bits) def _info_specs_text(info): """链接解析阶段从搜索参数页补全的参数。""" specs = (info or {}).get("specs") or {} return ";".join(f"{k} {v}" for k, v in list(specs.items())[:6]) def _pick_competitors(linked, category, anchor_price, limit=5): pool = [p for p in _all_products() if p.get("category") == category] if linked: pool = [p for p in pool if p["id"] != linked["id"]] if not pool: return [] if anchor_price: near = [p for p in pool if abs(p["price"] - anchor_price) <= max(anchor_price * 0.35, 300)] if len(near) < 3: near = sorted(pool, key=lambda p: abs(p["price"] - anchor_price)) near.sort(key=lambda p: (abs(p["price"] - anchor_price), -(p.get("rating") or 0))) else: near = sorted(pool, key=lambda p: (-(p.get("rating") or 0), -(p.get("sales") or 0), p["price"])) return near[:limit] def _md_cell(value): text = str(value if value is not None else "") return re.sub(r"\s+", " ", text).replace("|", "/").strip() or "【暂无公开参数】" def _price_cell(product, link_price=None, original=False): if original: if link_price: return f"¥{link_price:g}" ref = (product or {}).get("price") return "【暂无公开参数】" + (f"(站内参考 ¥{ref:g})" if ref else "") return f"¥{product['price']:g}" if product.get("price") else "【暂无公开参数】" def _short_specs(product): if not product: return "" items = _spec_items(product)[:3] return " / ".join(f"{i['label']} {i['value']}" for i in items if i.get("value")) def _weak_points(product, table_products): if not product: return "【暂无公开参数】" pid = product.get("id") others = [p for p in table_products if p is not product and p.get("id") != pid] facts = [] rating = product.get("rating") price = product.get("price") sales = product.get("sales") other_ratings = [p.get("rating") for p in others if p.get("rating")] other_prices = [p.get("price") for p in others if p.get("price")] other_sales = [p.get("sales") for p in others if p.get("sales")] if rating and other_ratings and rating < min(other_ratings) and min(other_ratings) - rating >= 0.5: facts.append(f"好评率 {rating}%,低于表中其他款") if price and other_prices and price > max(other_prices): facts.append("价格表中最高") if sales and other_sales and sales < min(other_sales) * 0.6: facts.append("销量相对偏低") if product.get("note"): facts.append(product["note"]) return ";".join(facts) if facts else "【暂无公开参数】" def _link_verdict(linked, link_price, competitors): anchor = link_price or (linked or {}).get("price") lines = [] if not competitors: lines.append("- 结论:暂未在站内找到同类型同价位的可比机型,先不下判断。") lines.append("- 建议:确认具体型号(如容量 / 版本)后再来对比,避免买错版本。") return lines if anchor: best = max(competitors, key=lambda p: (p.get("rating") or 0) - abs(p["price"] - anchor) / anchor * 12) else: best = max(competitors, key=lambda p: ((p.get("rating") or 0), p.get("sales") or 0)) if linked and anchor: lp, lr = linked["price"], linked.get("rating") or 0 cheaper_better = [p for p in competitors if p["price"] <= lp and (p.get("rating") or 0) >= lr] top_rating = max(p.get("rating") or 0 for p in competitors) if cheaper_better: names = "、".join(p["name"] for p in cheaper_better[:2]) lines.append(f"- 原商品值不值得买:**可以买,但不是这个价位的最优解**——{names} 更便宜且好评率不输。") elif lr >= top_rating and lp <= anchor * 1.12: lines.append("- 原商品值不值得买:**值得买**——同价位段口碑领先,没有明显贵得离谱。") else: lines.append("- 原商品值不值得买:**看需求**——没有明显价格优势,除非你认准它的品牌或生态。") elif linked: lines.append("- 原商品值不值得买:**看需求**——链接页未拿到标价,无法判断性价比,建议先在商品页确认到手价。") else: lines.append("- 原商品值不值得买:链接标题未匹配到站内同款,参数【暂无公开参数】,暂不给确定性结论。") reason = (best.get("highlights") or [""])[0] or (best.get("tags") or [""])[0] lines.append( f"- 优先推荐:**{best['name']}**(¥{best['price']:g},好评 {best.get('rating') or '暂无'}%" f")" + (f",{reason}" if reason else "") + "。" ) lines.append("- 价格提醒:链接标价与站内标价都以商品页为准,实际到手价会随券和活动变化。") return lines def _handle_link_compare(user_id, message, url): info = linkparse.parse_link(url) title = info.get("title") or "" link_price = info.get("price") platform = info.get("platform") or "电商平台" linked = _match_link_product(title) profile = get_profile(user_id) brand = "、".join(parse_brands(title)) or (linked or {}).get("brand") or "" category = _link_category(title, linked) anchor_price = link_price or (linked or {}).get("price") competitors = _pick_competitors(linked, category, anchor_price) if category else [] lines = ["**① 商品基础信息**", ""] display_name = title or (linked or {}).get("name") or "【暂无公开参数】" lines.append(f"- 商品名称:{display_name}") lines.append(f"- 品牌 / 型号:{brand or '【暂无公开参数】'} / {(linked or {}).get('name') or title or '【暂无公开参数】'}") lines.append(f"- 价格:{('¥%g' % link_price) if link_price else '【暂无公开参数】(页面价格需登录或动态加载,未能抓取)'}") lines.append(f"- 平台:{platform}") lines.append(f"- 购买链接:{info.get('finalUrl') or url}") if category: lines.append(f"- 所属品类:{category}") spec_text = (_short_specs(linked) or _info_specs_text(info) or _title_specs(title)) lines.append(f"- 核心参数:{spec_text or '【暂无公开参数】'}") if info.get("source"): src = info["source"] if info.get("specSource"): src += f",参数来源:{info['specSource']}" lines.append(f"- 解析来源:{src}") lines.append("") lines.append(f"**② 同价位同类型竞品({len(competitors)} 款)**") lines.append("") export_rows = [] if not competitors: lines.append("站内暂无可比机型。") else: table_products = ([linked] if linked else []) + competitors # 短板判断用「表格里展示的价格」,链接价优先,避免与表格数字打架 table_view = [] for p in table_products: item = dict(p) if linked and link_price and p.get("id") == linked.get("id"): item["price"] = link_price table_view.append(item) lines.append("| 商品 | 价格 | 核心配置 | 主要优势 | 短板 | 适合人群 |") lines.append("|---|---|---|---|---|---|") export_rows = [] if linked: name_cell = f"{linked['name']}(原商品)" else: name_cell = (title[:42] + "…" if len(title) > 42 else title) or "原商品" name_cell += "(原商品)" strengths = ";".join((linked or {}).get("highlights", [])[:2]) or "【暂无公开参数】" row = [ name_cell, _price_cell(linked, link_price, original=True), _short_specs(linked) or _info_specs_text(info) or _title_specs(title) or "【暂无公开参数】", strengths, _weak_points(linked, table_view), (linked or {}).get("audience") or "【暂无公开参数】", ] export_rows.append(row) lines.append("| " + " | ".join(_md_cell(v) for v in row) + " |") for p in competitors: strengths = ";".join(p.get("highlights", [])[:2]) or ";".join(p.get("tags", [])[:2]) or "【暂无公开参数】" row = [ p["name"], _price_cell(p), _short_specs(p) or "【暂无公开参数】", strengths, _weak_points(p, table_view), p.get("audience") or "【暂无公开参数】", ] export_rows.append(row) lines.append("| " + " | ".join(_md_cell(v) for v in row) + " |") lines.append("") lines.append("**③ 选购总结建议**") lines.append("") verdict_lines = _link_verdict(linked, link_price, competitors) lines.extend(verdict_lines) lines.append("") lines.append("> 说明:以上对比只用公开标价与商品页公开参数,未做任何夸大;参数缺失处均已标注【暂无公开参数】。") if competitors: lines.append("") lines.append("是否需要为您提供一份表格?") _remember_compare(user_id, { "title": "商品对比表", "headers": ["商品", "价格", "核心配置", "主要优势", "短板", "适合人群"], "rows": export_rows, "notes": [re.sub(r"\*\*", "", v.lstrip("- ")) for v in verdict_lines], "file_names": [row[0] for row in export_rows], }, [p["id"] for p in competitors]) for p in competitors: record_event(user_id, "view", p["id"]) reply = "\n".join(lines) quicks = [] if competitors: quicks.append("需要,给我一份表格") if linked: quicks.append("看看原商品详情") if competitors: quicks.append("对比这几款") quicks.append("再找几款更便宜的") return { "reply": reply, "markdown": True, "narrator": "template", "intent": "link", "dialect": None, "parsed": { "normalized": message, "budget": None, "brands": [brand] if brand else [], "scenes": [], "mentions": [linked["name"]] if linked else [], }, "products": [product_card(p, profile) for p in competitors], "compare": None, "plan": None, "quickReplies": quicks, "profile": profile_summary(profile), } # ---------------------------------------------------------------- 主入口 def handle_chat(user_id, message, dialect_hint=None): link_url, _platform = linkparse.find_link(message) if link_url: return _handle_link_compare(user_id, message, link_url) if _is_export_request(message): exported = _export_reply(user_id) if exported: return exported parsed = parse_query(message, dialect_hint) parsed = _enrich_parsed(parsed) profile = get_profile(user_id) compare_data = None relaxed = False web_ids = [] if parsed["intent"] in ("recommend", "detail", "compare"): live = run_web_search(parsed) web_ids = [p["id"] for p in live] if parsed["intent"] == "compare": compare_data = _resolve_compare(parsed, profile) if compare_data is None: parsed["intent"] = "recommend" if parsed["intent"] == "compare": cards = compare_data["products"] if compare_data else [] elif parsed["intent"] in ("greet", "profile", "unknown"): cards = [] elif parsed["intent"] == "detail": target = product_by_id(parsed["mentions"][0]) if parsed["mentions"] else product_by_id(web_ids[0]) if web_ids else None if target: cards = [product_card(target, profile, 96, ["你问的就是这款"])] else: parsed["intent"] = "recommend" cards = recommend(parsed, profile, limit=3) else: cards = recommend(parsed, profile, limit=3) strict_count = len(_apply_filters(parsed)) relaxed = len(cards) > strict_count if not cards: relaxed = True cards = recommend({"brands": [], "scenes": [], "keywords": [], "budget": None, "mentions": [], "intent": "recommend"}, profile, limit=3) extra = [i for i in web_ids if all(c["id"] != i for c in cards)] if extra: web_cards = [product_card(product_by_id(i), profile, 88, ["本地库没有,联网查到的在售机型"]) for i in extra[:3]] cards = (web_cards + [c for c in cards if c["id"] not in web_ids])[:3] for card in cards: record_event(user_id, "view", card["id"]) profile = record_search(user_id, parsed) if compare_data: for pid in compare_data["ids"]: record_event(user_id, "compare", pid) _remember_compare(user_id, _compare_export_model(compare_data), compare_data.get("ids")) plan = None if cards and parsed["intent"] == "recommend": plan = build_purchase_plan(cards, parsed, profile) reply, quicks = build_reply(parsed, cards, compare_data, profile, {"relaxed": relaxed, "plan": plan}) # 寒暄/查记忆/没听懂这三类用固定话术,避免模型硬凑成商品列表 llm_text = None if parsed["intent"] not in ("greet", "profile", "unknown"): llm_text = llm_narrate(parsed, cards, compare_data, profile, reply, plan) narrator = "llm" if llm_text else "template" if llm_text: reply = llm_text if compare_data and "是否需要为您提供一份表格" not in reply: reply = reply.rstrip() + "\n\n是否需要为您提供一份表格?" return { "reply": reply, "narrator": narrator, "intent": parsed["intent"], "dialect": parsed["dialect"], "parsed": { "normalized": parsed["normalized"], "budget": parsed["budget"], "brands": parsed["brands"], "scenes": parsed["scenes"], "mentions": [PRODUCT_BY_ID[i]["name"] for i in parsed["mentions"]], }, "products": cards, "compare": compare_data, "plan": plan, "quickReplies": quicks, "profile": profile_summary(profile), } def _resolve_compare(parsed, profile): mentions = parsed.get("mentions", []) web_ids = parsed.get("webIds", []) if len(mentions) >= 2: return build_compare(mentions[:3], profile, parsed) if mentions and web_ids: return build_compare([mentions[0], web_ids[0]], profile, parsed) if len(web_ids) >= 2: return build_compare(web_ids[:3], profile, parsed) if len(mentions) == 1: anchor = product_by_id(mentions[0]) rival_parsed = {"brands": [], "scenes": anchor["scenes"], "keywords": [], "budget": None, "mentions": [], "intent": "compare"} rivals = recommend(rival_parsed, profile, limit=2, exclude={anchor["id"]}) ids = [anchor["id"]] + [r["id"] for r in rivals[:1]] return build_compare(ids, profile, parsed) brands = parsed.get("brands", []) if len(brands) >= 2: ids = [] for b in brands[:3]: best = max([p for p in PRODUCTS if p["brand"] == b], key=lambda p: p["rating"] * 1000 + p["sales"] / 10) ids.append(best["id"]) return build_compare(ids, profile, parsed) if len(brands) == 1: candidates = [p for p in PRODUCTS if p["brand"] == brands[0]] candidates.sort(key=lambda p: -p["sales"]) if len(candidates) < 2: candidates = PRODUCTS return build_compare([candidates[0]["id"], candidates[1]["id"]], profile, parsed) scenes = parsed.get("scenes", []) if scenes: scored = [] for p in PRODUCTS: fit = len([s for s in scenes if s in p["scenes"]]) if fit: scored.append((fit, p["rating"], p)) scored.sort(key=lambda x: (-x[0], -x[1])) if len(scored) >= 2: return build_compare([scored[0][2]["id"], scored[1][2]["id"]], profile, parsed) return None # ---------------------------------------------------------------- 可选大模型解说 LLM_CONFIG_PATH = os.path.join(DATA_DIR, "llm_config.json") def _llm_conf(): """优先级:环境变量 > data/llm_config.json > 默认值。""" conf = {} if os.path.exists(LLM_CONFIG_PATH): try: with open(LLM_CONFIG_PATH, "r", encoding="utf-8") as f: conf = json.load(f) except (json.JSONDecodeError, OSError): conf = {} key = (os.environ.get("LLM_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY") or conf.get("apiKey") or "") base = (os.environ.get("LLM_API_BASE") or conf.get("apiBase") or "https://api.deepseek.com/v1").rstrip("/") model = os.environ.get("LLM_MODEL") or conf.get("model") or "deepseek-chat" return key, base, model def llm_enabled(): return bool(_llm_conf()[0]) def llm_narrate(parsed, cards, compare_data, profile, fallback, plan=None): """调用 OpenAI 兼容接口润色话术;任何异常都静默回落到模板话术。""" if not llm_enabled(): return None import urllib.error import urllib.request key, base, model = _llm_conf() facts = { "user_message": parsed.get("raw", ""), "user_need": _describe_needs(parsed), "products": [ {"name": c["name"], "price": c["price"], "rating": c["rating"], "sales": c["salesText"], "tags": c["tags"], "highlights": c["highlights"], "web": c.get("web", False), "note": c.get("note", "")} for c in cards ], "compare": [ {"label": r["label"], "values": r["values"]} for r in (compare_data or {}).get("rows", [])[:3] ], } if plan: facts["purchase_plan"] = { "budget": plan["budget"]["text"], "scenario": plan["scenario"]["text"], "primary": plan["primary"]["name"], "why_fit_user": plan["primary"]["why"], "reviews": plan["reviews"]["text"], "after_sales_risk": {"level": plan["risk"]["level"], "items": plan["risk"]["items"]}, "alternatives": [f"{a['name']} ¥{a['price']}({a['role']})" for a in plan["alternatives"]], "verdict": plan["verdict"], } system = ( "你的名字是“小函”。任何时候提到自己都自称“小函”,不要说 AI、助手、智能体、导购助手。\n" "你是谁不影响格式:只输出最终结果,不要开场白、寒暄、道歉、自我说明," "不要重复用户问题,也不要写“好的/当然/没问题/以下是/总结一下”这类废话。\n" "严格按下面的纯文本格式输出,不要使用 markdown 表格或额外装饰:\n" "【有 purchase_plan 时】\n" "主推:商品名|¥价格|一句理由\n" "适合你:不超过 40 字\n" "评价:不超过 25 字\n" "售后风险:级别|不超过 25 字\n" "备选:名称 ¥价格(定位);最多 2 款\n" "结论:不超过 30 字\n" "下单前:1)… 2)… 3)…\n" "【普通推荐】\n" "结论:不超过 30 字\n" "推荐:\n" "1. 名称|¥价格|好评率|一句理由(不超过 20 字)\n" "2. …\n" "3. …\n" "提醒:仅在有联网参考价、预算放宽或参数缺失时写一句,否则省略\n" "【对比或细节问答】\n" "结论:一句\n" "要点:2-3 条,每条不超过 25 字;不要重复表格内容\n" "硬性要求:总字数不超过 120 字(有 purchase_plan 时不超过 180 字);" "只能使用给定事实,严禁编造价格、参数、销量;每款商品只出现一次;" "标注 web=true 的商品是联网参考价,必须提醒以下单页面为准。" ) payload = { "model": model, "temperature": 0.2, "max_tokens": 500, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": json.dumps(facts, ensure_ascii=False) + "\n参考话术:" + fallback}, ], } req = urllib.request.Request( base + "/chat/completions", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) return data["choices"][0]["message"]["content"].strip() except Exception: return None def config_info(): key, base, model = _llm_conf() products = _all_products() return { "products": len(products), "localProducts": len(PRODUCTS), "categories": sorted({p.get("category", "其他") for p in products}), "brands": sorted({p["brand"] for p in products}), "dialects": DIALECTS["regions"], "speechLanguages": DIALECTS["speechLanguages"], "llm": {"enabled": bool(key), "model": model, "apiBase": base}, "webSearch": websearch.stats(), "database": db.info(), } def product_detail(product_id, profile=None): p = product_by_id(product_id) if not p: return None card = product_card(p, profile, 0, []) card["detail"] = {item["label"]: item["value"] for item in _spec_items(p)} model_file = os.path.join(BASE_DIR, "web", "models", f"{p['id']}.glb") card["model3d"] = f"/models/{p['id']}.glb" if os.path.isfile(model_file) else "" return card ``` --- ## db.py ```python # -*- coding: utf-8 -*- """商品与记忆库存储层。 默认 SQLite(零依赖);在 data/db_config.json 里把 backend 改成 mysql 并填连接串, 即可切换到 MySQL,不需要改业务代码。MySQL 连接失败时自动回落 SQLite 并在 /api/config 里提示。 """ import json import os import sqlite3 import threading from datetime import datetime BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") CONFIG_PATH = os.path.join(DATA_DIR, "db_config.json") SQLITE_PATH = os.path.join(DATA_DIR, "shop.db") CATALOG_PATH = os.path.join(DATA_DIR, "catalog.json") LEGACY_MEMORY = os.path.join(DATA_DIR, "memory.json") _lock = threading.RLock() _STATE = {"backend": None, "warning": ""} SQLITE_SCHEMA = """ CREATE TABLE IF NOT EXISTS products ( id TEXT PRIMARY KEY, name TEXT, brand TEXT, category TEXT, brand_tier TEXT, price REAL, origin TEXT, payload TEXT NOT NULL, updated_at TEXT ); CREATE INDEX IF NOT EXISTS idx_products_category ON products(category); CREATE INDEX IF NOT EXISTS idx_products_origin ON products(origin); CREATE TABLE IF NOT EXISTS memory ( user_id TEXT PRIMARY KEY, payload TEXT NOT NULL, updated_at TEXT ); """ MYSQL_SCHEMA = [ """CREATE TABLE IF NOT EXISTS products ( id VARCHAR(64) PRIMARY KEY, name VARCHAR(160), brand VARCHAR(80), category VARCHAR(64), brand_tier VARCHAR(32), price DECIMAL(10,2), origin VARCHAR(32), payload LONGTEXT NOT NULL, updated_at DATETIME, INDEX idx_products_category (category), INDEX idx_products_origin (origin) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", """CREATE TABLE IF NOT EXISTS memory ( user_id VARCHAR(128) PRIMARY KEY, payload LONGTEXT NOT NULL, updated_at DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ] def _conf(): conf = {} if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: conf = json.load(f) except (json.JSONDecodeError, OSError): conf = {} backend = (os.environ.get("DB_BACKEND") or conf.get("backend") or "sqlite").lower() mysql = conf.get("mysql", {}) or {} mysql = { "host": os.environ.get("MYSQL_HOST") or mysql.get("host", "127.0.0.1"), "port": int(os.environ.get("MYSQL_PORT") or mysql.get("port", 3306)), "user": os.environ.get("MYSQL_USER") or mysql.get("user", "root"), "password": os.environ.get("MYSQL_PASSWORD") or mysql.get("password", ""), "database": os.environ.get("MYSQL_DATABASE") or mysql.get("database", "smart_shopping"), } return backend, mysql def connect(): """返回 (driver, connection, actual_backend)。MySQL 不可用时自动回落。""" backend, mysql = _conf() if backend == "mysql": try: import pymysql except ImportError: _STATE["warning"] = "未安装 pymysql,已回落 SQLite(pip install pymysql 后可用 MySQL)" backend = "sqlite" else: try: conn = pymysql.connect(host=mysql["host"], port=mysql["port"], user=mysql["user"], password=mysql["password"], database=mysql["database"], charset="utf8mb4", autocommit=True, connect_timeout=6) _STATE.update(backend="mysql", warning="") return "mysql", conn, "mysql" except Exception as exc: _STATE["warning"] = f"MySQL 连接失败,已回落 SQLite({type(exc).__name__})" backend = "sqlite" os.makedirs(DATA_DIR, exist_ok=True) conn = sqlite3.connect(SQLITE_PATH, timeout=10, check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") _STATE["backend"] = "sqlite" return "sqlite", conn, "sqlite" def _ensure_schema(driver, conn): if driver == "sqlite": conn.executescript(SQLITE_SCHEMA) conn.commit() else: with conn.cursor() as cur: for statement in MYSQL_SCHEMA: cur.execute(statement) def _decode(row_payload): if isinstance(row_payload, (bytes, bytearray)): row_payload = row_payload.decode("utf-8") return json.loads(row_payload) def init(): """建表 + 首次导入目录数据 + 迁移旧 JSON 记忆库。""" with _lock: driver, conn, _ = connect() _ensure_schema(driver, conn) total = count_products(driver, conn) if total == 0 and os.path.exists(CATALOG_PATH): with open(CATALOG_PATH, "r", encoding="utf-8") as f: catalog = json.load(f) _upsert(driver, conn, catalog) if driver == "sqlite" and count_memory(driver, conn) == 0 and os.path.exists(LEGACY_MEMORY): try: with open(LEGACY_MEMORY, "r", encoding="utf-8") as f: legacy = json.load(f) for uid, profile in legacy.items(): save_memory(uid, profile) except (json.JSONDecodeError, OSError): pass conn.close() def count_products(driver=None, conn=None): own = conn is None if own: driver, conn, _ = connect() try: if driver == "sqlite": cur = conn.execute("SELECT COUNT(*) FROM products") else: cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM products") return int(cur.fetchone()[0]) finally: if own: conn.close() def count_memory(driver=None, conn=None): own = conn is None if own: driver, conn, _ = connect() try: if driver == "sqlite": cur = conn.execute("SELECT COUNT(*) FROM memory") else: cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM memory") return int(cur.fetchone()[0]) finally: if own: conn.close() def load_products(origin=None): driver, conn, _ = connect() try: sql = "SELECT payload FROM products" args = () if origin: sql += " WHERE origin = " + ("?" if driver == "sqlite" else "%s") args = (origin,) cur = conn.execute(sql, args) if driver == "sqlite" else conn.cursor() if driver != "sqlite": cur.execute(sql, args) rows = cur.fetchall() items = [] for row in rows: try: items.append(_decode(row[0])) except (json.JSONDecodeError, TypeError): continue items.sort(key=lambda p: (p.get("category") or "", -float(p.get("price") or 0))) return items finally: conn.close() def load_product(product_id): driver, conn, _ = connect() try: sql = "SELECT payload FROM products WHERE id = " + ("?" if driver == "sqlite" else "%s") if driver == "sqlite": cur = conn.execute(sql, (product_id,)) else: cur = conn.cursor() cur.execute(sql, (product_id,)) row = cur.fetchone() return _decode(row[0]) if row else None except (sqlite3.Error, json.JSONDecodeError): return None finally: conn.close() def _upsert(driver, conn, items): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") for item in items: if not item.get("id"): continue payload = json.dumps(item, ensure_ascii=False) if driver == "sqlite": conn.execute( """INSERT INTO products (id, name, brand, category, brand_tier, price, origin, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, brand=excluded.brand, category=excluded.category, brand_tier=excluded.brand_tier, price=excluded.price, origin=excluded.origin, payload=excluded.payload, updated_at=excluded.updated_at""", (item["id"], item.get("name", ""), item.get("brand", ""), item.get("category", ""), item.get("brandTier", ""), float(item.get("price") or 0), item.get("origin", "seed"), payload, now), ) else: with conn.cursor() as cur: cur.execute( """INSERT INTO products (id, name, brand, category, brand_tier, price, origin, payload, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE name=VALUES(name), brand=VALUES(brand), category=VALUES(category), brand_tier=VALUES(brand_tier), price=VALUES(price), origin=VALUES(origin), payload=VALUES(payload), updated_at=VALUES(updated_at)""", (item["id"], item.get("name", ""), item.get("brand", ""), item.get("category", ""), item.get("brandTier", ""), float(item.get("price") or 0), item.get("origin", "seed"), payload, now), ) if driver == "sqlite": conn.commit() def upsert_products(items): if not items: return 0 with _lock: driver, conn, _ = connect() try: _upsert(driver, conn, items) return len(items) finally: conn.close() def load_memory(): driver, conn, _ = connect() try: cur = conn.execute("SELECT user_id, payload FROM memory") if driver == "sqlite" else conn.cursor() if driver != "sqlite": cur.execute("SELECT user_id, payload FROM memory") memory = {} for uid, payload in cur.fetchall(): try: memory[uid] = _decode(payload) except (json.JSONDecodeError, TypeError): continue return memory finally: conn.close() def save_memory(user_id, profile): payload = json.dumps(profile, ensure_ascii=False) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with _lock: driver, conn, _ = connect() try: if driver == "sqlite": conn.execute( """INSERT INTO memory (user_id, payload, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at""", (user_id, payload, now), ) conn.commit() else: with conn.cursor() as cur: cur.execute( """INSERT INTO memory (user_id, payload, updated_at) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE payload=VALUES(payload), updated_at=VALUES(updated_at)""", (user_id, payload, now), ) finally: conn.close() def delete_memory(user_id): with _lock: driver, conn, _ = connect() try: if driver == "sqlite": conn.execute("DELETE FROM memory WHERE user_id = ?", (user_id,)) conn.commit() else: with conn.cursor() as cur: cur.execute("DELETE FROM memory WHERE user_id = %s", (user_id,)) finally: conn.close() def info(): return { "backend": _STATE.get("backend") or "sqlite", "configured": _conf()[0], "warning": _STATE.get("warning", ""), "sqlitePath": os.path.relpath(SQLITE_PATH, BASE_DIR), } ``` --- ## linkparse.py ```python """淘宝 / 京东商品链接解析(纯标准库,无第三方依赖)。 解析顺序: 1. 直接抓取商品页,提取 og:title / / meta description / 价格字段; 2. 页面是骨架屏或被反爬拦截时,用搜索引擎按商品 ID 反查标题与价格; 3. 仍拿不到的字段返回空值,由调用方标注【暂无公开参数】。 """ import html import json import re import time import urllib.parse import urllib.request import websearch URL_RE = re.compile(r"(?:https?://)?(?:[a-z0-9-]+\.)?" r"(taobao\.com|tmall\.com|tb\.cn|jd\.com|3\.cn)[^\s\u4e00-\u9fa5]*", re.I) PLATFORM_LABEL = {"taobao": "淘宝", "jd": "京东"} UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") UA_MOBILE = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1") def find_link(text): """从一段文本里找出第一个淘宝/京东链接。""" for match in URL_RE.finditer(text or ""): url = match.group(0).rstrip(".,;:)]}>\"'") if not url.lower().startswith("http"): url = "https://" + url return url, _platform(url) return None, None def _platform(url): host = urllib.parse.urlparse(url).netloc.lower() if "jd.com" in host or host.endswith("3.cn"): return "jd" if "taobao.com" in host or "tmall.com" in host or "tb.cn" in host: return "taobao" return None def _clean_title(title): title = html.unescape(title or "").strip() title = re.sub(r"【[^】]*行情[^】]*】|【[^】]*报价[^】]*】|【[^】]*图片[^】]*评论[^】]*】", "", title) title = re.sub(r"(京东购物-商品详情|京东\(JD\.COM\).*|淘宝网.*|天猫.*|淘!我喜欢.*)", "", title) title = re.split(r"[-_|]\s*(?:京东|淘宝|天猫)", title)[0] title = re.sub(r"\s+", " ", title).strip(" -|_") title = title.replace("...", "").strip() return title.strip() def _meta(html_text, names): for name in names: pattern = re.compile(r"<meta[^>]+>", re.I) for tag in pattern.findall(html_text): if re.search(r'(?:name|property)=["\']%s["\']' % re.escape(name), tag, re.I): m = re.search(r'content=["\']([^"\']*)["\']', tag, re.I) if m: return html.unescape(m.group(1)).strip() return "" def _fetch(url, timeout=12, mobile=False): req = urllib.request.Request(url, headers={ "User-Agent": UA_MOBILE if mobile else UA, "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "text/html,application/json,application/xhtml+xml", "Referer": "https://item.jd.com/", }) with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() charset = resp.headers.get_content_charset() or "utf-8" return resp.geturl(), raw.decode(charset, "ignore") PRICE_PATTERNS = [ r'"p":"([\d.]+)"', r'"price":"([\d.]+)"', r'"jdPrice":"([\d.]+)"', r'"lowestPrice":([\d.]+)', r'"salePrice":([\d.]+)', ] SNIPPET_PRICE_PATTERNS = PRICE_PATTERNS + [ r'[¥¥]\s*([\d,]+(?:\.\d+)?)', r'到手价[:: ]?\s*[¥¥]?\s*([\d,]+(?:\.\d+)?)', ] def _price_from(text, patterns=None): if not text: return None for pattern in (patterns or PRICE_PATTERNS): for m in re.finditer(pattern, text): raw = m.group(1).replace(",", "") try: value = float(raw) except ValueError: continue if 1 <= value <= 1000000: return value return None def _price_from_snippets(results): for item in results: blob = f"{item.get('title', '')} {item.get('snippet', '')}" price = _price_from(blob, SNIPPET_PRICE_PATTERNS) if price: return price return None def _search_title(platform, item_id): label = PLATFORM_LABEL[platform] domain = "item.jd.com" if platform == "jd" else "item.taobao.com" queries = [] if item_id: queries.append(f"site:{domain} {item_id}") queries.append(f"{item_id} {label} 商品 价格") for query in queries: try: results, _provider = websearch.search(query, n=5) except Exception: continue if not results: continue picked = None for item in results: title = _clean_title(item.get("title", "")) url = item.get("url", "") if not title or len(title) < 4: continue if item_id and (item_id in url or item_id in title): picked = title break if not picked and (label in title or label in item.get("snippet", "")): picked = title if picked: return picked, _price_from_snippets(results) return "", None def _item_id(platform, url): parsed = urllib.parse.urlparse(url) qs = urllib.parse.parse_qs(parsed.query) for key in ("id", "itemId", "sku", "wareId"): if qs.get(key): return qs[key][0] m = re.search(r"/(\d{8,})\.html", parsed.path) if m: return m.group(1) m = re.search(r"/(\d{8,})", parsed.path) if m: return m.group(1) return "" def _jd_price_api(item_id): if not item_id: return None try: _, text = _fetch(f"https://p.3.cn/prices/mgets?skuIds=J_{item_id}", timeout=8) return _price_from(text) except Exception: return None # ---------------------------------------------------------------- 参数补全 SPEC_TTL = 1800 _SPEC_CACHE = {} SPEC_LABELS = [ ("处理器", ["处理器", "cpu型号", "cpu频率", "cpu"]), ("屏幕", ["屏幕尺寸", "主屏尺寸", "屏幕大小", "屏幕"]), ("电池", ["电池容量", "电池类型", "电池"]), ("充电", ["有线充电", "充电功率", "快充", "充电"]), ("影像", ["像素", "后置摄像头", "摄像头", "影像"]), ("内存", ["运行内存", "ram容量", "ram"]), ("存储", ["机身存储", "rom容量", "存储容量", "存储"]), ("重量", ["机身重量", "整机重量", "重量"]), ("系统", ["操作系统", "系统版本", "系统"]), ] SPEC_SKIP = ("型号", "价格", "商家", "在售", "上市日期", "保修", "包装清单", "更多参数") SPEC_SKIP_EXACT = {"型号", "产品型号", "上市日期", "国内发布时间", "国外发布时间", "电商报价", "价格/商家", "更多参数"} SPEC_EXTRACT = { "屏幕": r"(?<![\d.])\d{1,2}(?:\.\d+)?\s*(?:英寸|寸)", "电池": r"\d{3,5}\s*mAh", "充电": r"\d{2,3}(?:\.\d+)?\s*[Ww]", "影像": r"\d+(?:\.\d+)?\s*(?:万像素|MP)", "内存": r"\d{1,2}\s*GB", "存储": r"\d{2,4}\s*(?:GB|TB)", "重量": r"\d{2,3}\s*(?:g|克)", } SPEC_NOISE = ("官方旗舰店", "旗舰店", "自营", "正品", "新品", "现货", "预售", "包邮", "全国联保", "双卡双待", "全网通", "全新", "未拆封", "手机", "黑色", "白色", "蓝色", "绿色", "灰色", "银色", "金色", "紫色", "粉色", "橙色") SPEC_TEXT_PATTERNS = { "处理器": r"(?:骁龙|天玑|麒麟|猎户座|Exynos)\s*[0-9A-Za-z至尊版()()]{1,12}", "屏幕": r"\d{2}(?:\.\d+)?\s*(?:英寸|寸)", "电池": r"\d{3,5}\s*mAh", "充电": r"\d{2,3}\s*W(?:\s*(?:有线|快充|闪充|无线))?", "内存": r"\d{1,2}\s*GB\s*(?:内存|RAM|运行内存)", "存储": r"\d{2,4}\s*(?:GB|TB)\s*(?:存储|ROM|机身存储)", "重量": r"\d{2,3}\s*(?:g|克)", "系统": r"(?:iOS|Android|HarmonyOS|鸿蒙)\s*\d*(?:\.\d+)?", } SEO_BLOB_WORDS = ( "京东JD.COM是国内专业的网上购物商城", "为您提供", "淘宝网", "淘!我喜欢", "登录", "验证", ) def _plain(fragment): text = re.sub(r"<[^>]+>", " ", fragment or "") text = html.unescape(text) return re.sub(r"\s+", " ", text).strip() def _clean_spec_value(value): text = re.sub(r"纠错|查看外观|手机性能排行|游戏运行流畅|大电池", " ", value or "") text = re.split(r"\s*更多|\s*查看", text)[0] text = text.replace(">", " ").replace(">", " ") return re.sub(r"\s+", " ", text).strip()[:30] def _specs_from_param_page(page): """解析中关村在线等参数页的 <th>参数名</th><td>参数值</td> 结构。""" raw, seen = [], set() for block in re.findall(r"<tr[^>]*>(.*?)</tr>", page or "", re.S | re.I): m = re.search(r"<th[^>]*>(.*?)</th>", block, re.S | re.I) if not m: continue label = _plain(m.group(1)) if not label or label in seen: continue tds = re.findall(r"<td[^>]*>(.*?)</td>", block, re.S | re.I) if not tds: continue value = _plain(tds[0]) if not value: continue if label in SPEC_SKIP_EXACT: continue if any(word in label for word in ("价格", "商家", "在售", "上市日期", "保修", "包装清单")): continue seen.add(label) raw.append((label, _clean_spec_value(value))) specs = {} for canonical, aliases in SPEC_LABELS: for alias in aliases: found = "" for label, value in raw: if alias not in label.replace(" ", "").lower(): continue if alias == "像素" and "密度" in label: continue found = value break if not found: continue pattern = SPEC_EXTRACT.get(canonical) if pattern: m = re.search(pattern, found, re.I) if not m: continue found = re.sub(r"\s+", "", m.group(0)) if canonical == "充电": found = found.replace("w", "W") specs[canonical] = found break return specs BRAND_PREFIXES = ("苹果", "Apple", "小米", "红米", "Redmi", "华为", "荣耀", "OPPO", "vivo", "一加", "三星", "Samsung", "真我", "realme", "魅族") def _strip_brand(text): for brand in BRAND_PREFIXES: if (text or "").startswith(brand): return text[len(brand):].strip() return text def _spec_query(title): """把营销标题压成适合搜索的参数查询词。""" text = re.sub(r"[((][^))]*[))]", " ", title or "") text = re.sub(r"\b\d+(?:\.\d+)?\s*(?:GB|TB|G|T)\b", " ", text, flags=re.I) text = re.sub(r"([\u4e00-\u9fff])([A-Za-z])", r"\1 \2", text) text = re.sub(r"([A-Za-z])([\u4e00-\u9fff])", r"\1 \2", text) text = re.split(r"支持", text)[0] for word in SPEC_NOISE: text = text.replace(word, " ") text = re.sub(r"[【】\[\]()(),,、|/]+", " ", text) parts = [p for p in text.split() if p] return " ".join(parts[:4]) or (title or "")[:24] def _specs_from_text(text): specs = {} for canonical, pattern in SPEC_TEXT_PATTERNS.items(): m = re.search(pattern, text or "", re.I) if m: value = re.sub(r"\s+", " ", m.group(0)).strip() if value: specs[canonical] = value return specs def _order_specs(specs): ordered = {} for canonical, _aliases in SPEC_LABELS: if specs.get(canonical): ordered[canonical] = specs[canonical] for key, value in specs.items(): if key not in ordered and value: ordered[key] = value return ordered def format_specs(specs): return ";".join(f"{k} {v}" for k, v in (specs or {}).items() if v) def enrich_specs(title): """用搜索引擎找参数页 / 摘要,补齐链接商品的核心参数,返回 (参数, 来源)。""" key = re.sub(r"\s+", " ", title or "").strip()[:80] if len(key) < 4: return {}, "" now = time.time() hit = _SPEC_CACHE.get(key) if hit and now - hit[0] < SPEC_TTL: return hit[1], hit[2] core = _spec_query(key) bases = [] stripped = _strip_brand(core) if stripped != core and re.search(r"[A-Za-z]{3,}", stripped): bases.append(stripped) bases.append(core) if key not in bases: bases.append(key) queries = [] for base in bases: for suffix in (" 参数", " 配置"): query = (base + suffix).strip() if query not in queries and len(query) >= 5: queries.append(query) items, zol_items = [], [] for query in queries: try: items = websearch._bing(query, n=8) except Exception: items = [] if not items: continue zol_items = [i for i in items if "zol.com.cn" in i.get("url", "") and "param" in i.get("url", "")] if zol_items: break if not items and queries: try: items, _provider = websearch.search(queries[0], n=6) except Exception: items = [] specs, source = {}, "" for item in zol_items: try: _, page = _fetch(item["url"], timeout=12) found = _specs_from_param_page(page) except Exception: found = {} if len(found) >= 3: specs, source = found, "中关村在线" break if len(specs) < 3: blob = " ".join(f"{i.get('title', '')} {i.get('snippet', '')}" for i in items) from_text = _specs_from_text(blob) if from_text: specs = dict(from_text, **specs) if specs else from_text source = source or "搜索摘要" specs = _order_specs(specs) _SPEC_CACHE[key] = (now, specs, source) if len(_SPEC_CACHE) > 300: for k in list(_SPEC_CACHE)[:100]: _SPEC_CACHE.pop(k, None) return specs, source def _is_seo_blob(text): return any(word in (text or "") for word in SEO_BLOB_WORDS) def _valid_title(title): if not title or len(title) < 6: return False generic = ["京东购物", "商品详情", "淘宝网", "天猫", "登录", "验证", "404"] return not any(g in title for g in generic) def parse_link(url): platform = _platform(url) or "" result = { "url": url, "finalUrl": "", "platform": PLATFORM_LABEL.get(platform, ""), "itemId": "", "title": "", "price": None, "spec": "", "specs": {}, "specSource": "", "source": "", } if not platform: return result page_title = "" page_spec = "" page_price = None final_url = url try: final_url, page = _fetch(url, mobile=True) og_title = _meta(page, ["og:title", "twitter:title"]) if not og_title: m = re.search(r"<title[^>]*>(.*?)", page, re.S | re.I) og_title = html.unescape(m.group(1)) if m else "" page_title = _clean_title(og_title) page_spec = _meta(page, ["description", "og:description"]) page_price = _price_from(page) if platform == "jd": page_price = page_price or _jd_price_api(_item_id(platform, final_url)) except Exception: pass result["finalUrl"] = final_url result["itemId"] = _item_id(platform, final_url) or _item_id(platform, url) title = page_title if _valid_title(page_title) else "" price = page_price source = "商品页" if title else "" if not title: searched, searched_price = _search_title(platform, result["itemId"]) if searched: title = searched source = "搜索引擎" price = price or searched_price result["title"] = title result["price"] = price if title: specs, spec_source = enrich_specs(title) result["specs"] = specs result["specSource"] = spec_source result["spec"] = format_specs(specs) if not result["spec"] and page_spec and len(page_spec) >= 8 and not _is_seo_blob(page_spec): result["spec"] = page_spec[:180] result["source"] = source or "解析失败" return result if __name__ == "__main__": import sys for arg in sys.argv[1:] or ["https://item.jd.com/100012043978.html"]: print(json.dumps(parse_link(arg), ensure_ascii=False, indent=2)) ``` --- ## websearch.py ```python # -*- coding: utf-8 -*- """联网商品搜索:360/Bing 网页搜索(免密钥,可选 Tavily/Serper)→ 大模型抽取结构化商品事实。 只作为补充信源:本地商品库仍是主数据,联网结果标记 origin=web、价格标注“参考价”。 """ import hashlib import json import os import re import threading import time import urllib.error import urllib.parse import urllib.request from datetime import datetime BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") CONFIG_PATH = os.path.join(DATA_DIR, "llm_config.json") UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36") _lock = threading.RLock() _CACHE = {} _TTL = 900 # 同一查询 15 分钟内不重复联网 import db BRAND_COLORS = { "小米": ("#ff6900", "#ffb26b"), "红米": ("#f04a23", "#ff8f6b"), "苹果": ("#3a3f4b", "#7d8497"), "华为": ("#c8102e", "#ff6b7f"), "荣耀": ("#0aa1e0", "#69d0ff"), "OPPO": ("#0f8f5f", "#5cd3a4"), "vivo": ("#2b6cff", "#7ea6ff"), "一加": ("#e02a2a", "#ff7b7b"), "三星": ("#1f4cbf", "#6f93ff"), } TIER_MAP = {"高奢": "luxury", "热门": "popular", "经济": "economy", "小众": "niche", "luxury": "luxury", "popular": "popular", "economy": "economy", "niche": "niche"} SCENE_HINTS = { "拍照": ["影像", "拍照", "相机", "长焦", "夜景", "徕卡", "蔡司", "哈苏"], "游戏": ["游戏", "性能", "散热", "高刷", "电竞", "骁龙", "天玑"], "续航": ["续航", "电池", "快充", "青海湖"], "小屏": ["小屏", "单手", "轻巧"], "大屏": ["大屏", "6.7", "6.8"], "轻薄": ["轻薄", "轻至", "毫米", "克"], "性价比": ["性价比", "千元", "亲民", "白菜"], "折叠": ["折叠", "flip", "fold"], "商务": ["商务", "办公", "旗舰"], "自拍": ["自拍", "前置"], "系统": ["系统", "iOS", "鸿蒙", "纯净"], "信号": ["信号", "卫星", "北斗"], "音乐": ["音质", "LDAC", "Hi-Res", "音频"], "通勤": ["通勤", "降噪", "便携"], "健康": ["心率", "血氧", "睡眠", "监测"], "居家": ["家用", "家居", "客厅", "厨房", "清洁"], "护肤": ["护肤", "精华", "保湿", "修护"], "运动": ["运动", "跑步", "骑行", "健身"], "户外": ["户外", "露营", "徒步", "防水"], "零食": ["零食", "坚果", "酸奶", "冲泡"], } # ---------------------------------------------------------------- 配置 def _conf(): conf = {} if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: conf = json.load(f) except (json.JSONDecodeError, OSError): conf = {} key = (os.environ.get("LLM_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY") or conf.get("apiKey") or "") base = (os.environ.get("LLM_API_BASE") or conf.get("apiBase") or "https://api.deepseek.com/v1").rstrip("/") model = os.environ.get("LLM_MODEL") or conf.get("model") or "deepseek-chat" return key, base, model, conf.get("webSearch", {}) or {} def enabled(): """联网检索至少有一条可用路径:免费 Bing,或配置了 Tavily/Serper 的 Key。""" return True # ---------------------------------------------------------------- 联网商品存储(统一走数据库) def all_products(): return db.load_products(origin="web") def get(product_id): product = db.load_product(product_id) return product if product and product.get("origin") == "web" else None def _register(products): """写入联网商品库;已存在同名商品则复用旧记录,保证重复查询也能返回结果。""" if not products: return [] existing = {p["name"]: p for p in all_products()} result, new_items = [], [] for p in products: if p["name"] in existing: result.append(existing[p["name"]]) continue new_items.append(p) result.append(p) if new_items: db.upsert_products(new_items) return result # ---------------------------------------------------------------- 网页搜索 def _fetch(url, timeout=10, data=None, headers=None): h = {"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"} h.update(headers or {}) req = urllib.request.Request(url, data=data, headers=h, method="POST" if data else "GET") with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read().decode("utf-8", "ignore") def _strip(text): text = re.sub(r"<[^>]+>", "", text or "") text = (text.replace(" ", " ").replace("&", "&").replace(""", '"') .replace("'", "'").replace("<", "<").replace(">", ">")) return re.sub(r"\s+", " ", text).strip() def _bing(query, n=6): url = ("https://cn.bing.com/search?q=" + urllib.parse.quote(query) + "&count=10&setlang=zh-CN&mkt=zh-CN") html = _fetch(url, timeout=10) items = [] for block in re.findall(r'
  • ', html, re.S): m = re.search(r']*>.*?]*href="([^"]+)"[^>]*>(.*?)', block, re.S) if not m: continue href, title = m.group(1), _strip(m.group(2)) sm = re.search(r']*>(.*?)

    ', block, re.S) snippet = _strip(sm.group(1)) if sm else "" if title: items.append({"title": title[:120], "snippet": snippet[:400], "url": href[:300]}) if len(items) >= n: break return items def _so360(query, n=6): html = _fetch("https://www.so.com/s?q=" + urllib.parse.quote(query) + "&pn=1", timeout=10) starts = [m.start() for m in re.finditer(r']+class="[^"]*res-list[^"]*"', html)] items = [] for i, start in enumerate(starts): end = starts[i + 1] if i + 1 < len(starts) else start + 2600 block = html[start:end] m = re.search(r"]*>(.*?)", block, re.S) if not m: continue title = _strip(m.group(1))[:120] if not title or "搜了" in title: continue body = _strip(block) if body.startswith(title): body = body[len(title):].strip() href = re.search(r'href="(http[^"]+)"', block) items.append({"title": title, "snippet": body[:400], "url": href.group(1)[:300] if href else ""}) if len(items) >= n: break return items def _tavily(query, n=6): key, _, _, _ = _conf() if not key: return [] payload = {"api_key": key, "query": query, "max_results": n, "search_depth": "basic"} raw = _fetch("https://api.tavily.com/search", timeout=15, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}) data = json.loads(raw) return [{"title": r.get("title", "")[:120], "snippet": (r.get("content") or "")[:400], "url": r.get("url", "")[:300]} for r in data.get("results", [])][:n] def _serper(query, n=6): _, _, _, ws = _conf() key = ws.get("apiKey") or "" if not key: return [] raw = _fetch("https://google.serper.dev/search", timeout=15, data=json.dumps({"q": query, "num": n}).encode("utf-8"), headers={"Content-Type": "application/json", "X-API-KEY": key}) data = json.loads(raw) return [{"title": r.get("title", "")[:120], "snippet": (r.get("snippet") or "")[:400], "url": r.get("link", "")[:300]} for r in data.get("organic", [])][:n] def _relevance(items, query): """判断搜索结果是否真的和查询主体(通常是机型名)相关。""" head = (query or "").split(" ")[0] head = re.sub(r"\s", "", head).lower() if not head: return 0 hits = 0 for item in items: text = (item["title"] + item["snippet"]).replace(" ", "").lower() if head in text: hits += 1 return hits def search(query, n=6): _, _, _, ws = _conf() provider = ws.get("provider") or "auto" if provider == "auto": order = ["so360", "bing", "tavily", "serper"] else: order = {"so360": ["so360", "bing"], "bing": ["bing", "so360"], "tavily": ["tavily", "so360", "bing"], "serper": ["serper", "so360", "bing"]}.get(provider, ["so360", "bing"]) best = None for name in order: try: items = {"so360": _so360, "bing": _bing, "tavily": _tavily, "serper": _serper}[name](query, n) except Exception: continue if not items: continue rel = _relevance(items, query) if best is None or rel > best[1]: best = (items, rel, name) if rel >= 2: return items, name if best: return best[0], best[2] return [], None # ---------------------------------------------------------------- 大模型抽取 def _llm_json(system, user, max_tokens=900, timeout=20): key, base, model, _ = _conf() if not key: return None payload = { "model": model, "temperature": 0.2, "max_tokens": max_tokens, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], } req = urllib.request.Request( base + "/chat/completions", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) return json.loads(data["choices"][0]["message"]["content"]) except Exception: return None EXTRACT_SYSTEM = ( "你是商品信息抽取器。根据搜索摘要抽取 1-3 个真实在售的同品类商品,只输出 JSON:" '{"products":[{"name":"完整型号名","category":"手机/笔记本/平板/耳机/智能穿戴/家电/美妆/食品/服饰/家居/其他",' '"brand":"品牌名","brandTier":"高奢/热门/经济/小众","price":整数元,' '"tags":["最多4个短标签"],"highlights":["最多3条亮点"],"audience":"一句话适合人群",' '"environments":["室内","室外"],"specs":{"参数名":"参数值"}}]}。' "规则:价格必须是摘要中出现的整数人民币;摘要没提到的字段给空数组/空对象;不要臆造未出现的型号;" "摘要里没有明确价格时返回 {\"products\":[]}。" ) def _infer_scenes(text): hits = [] for scene, words in SCENE_HINTS.items(): if any(w.lower() in text.lower() for w in words): hits.append(scene) return hits[:4] or ["均衡"] def _sanitize(raw, source_urls): if not isinstance(raw, dict): return None name = str(raw.get("name") or "").strip()[:40] if len(name) < 3: return None price_text = re.sub(r"[^\d]", "", str(raw.get("price") or "")) if not price_text: return None price = int(price_text) if not (50 <= price <= 60000): return None brand = str(raw.get("brand") or "").strip() or name[:2] category = str(raw.get("category") or "").strip()[:16] or "其他" tier = TIER_MAP.get(str(raw.get("brandTier") or "").strip(), "popular") tags = [str(t).strip()[:8] for t in (raw.get("tags") or []) if str(t).strip()][:4] highlights = [str(h).strip()[:40] for h in (raw.get("highlights") or []) if str(h).strip()][:3] if not tags: tags = ["联网新品", "参考价"] if not highlights: highlights = ["来自联网检索,详情以官方页面为准"] audience = str(raw.get("audience") or "").strip()[:60] or "关注该商品的用户" env_map = {"室内": "indoor", "室外": "outdoor", "indoor": "indoor", "outdoor": "outdoor"} envs = [env_map[e] for e in (raw.get("environments") or []) if e in env_map] envs = list(dict.fromkeys(envs)) or ["indoor", "outdoor"] raw_specs = raw.get("specs") if isinstance(raw.get("specs"), dict) else {} specs = {str(k)[:20]: str(v)[:40] for k, v in raw_specs.items() if v} specs = dict(list(specs.items())[:6]) or {"类型": category} color, color2 = BRAND_COLORS.get(brand, ("#5b6b8c", "#9aa8c2")) pid = "web-" + hashlib.md5(name.encode("utf-8")).hexdigest()[:8] text = name + " ".join(tags) + " ".join(highlights) + audience return { "id": pid, "name": name, "brand": brand, "brandTier": tier, "category": category, "price": price, "rating": None, "sales": None, "tags": tags, "highlights": highlights, "audience": audience, "scenes": _infer_scenes(text), "environments": envs, "specs": specs, "color": color, "color2": color2, "origin": "web", "estimated": True, "sourceUrl": (source_urls[0] if source_urls else ""), "fetchedAt": datetime.now().strftime("%Y-%m-%d %H:%M"), "note": "联网检索的参考价,实际以官方/电商页面为准", } def extract_products(query, results): if not results: return [] facts = [{"title": r["title"], "snippet": r["snippet"]} for r in results[:6]] data = _llm_json(EXTRACT_SYSTEM, f"用户查询:{query}\n搜索结果:{json.dumps(facts, ensure_ascii=False)}") if not data: return [] urls = [r["url"] for r in results if r.get("url")] out = [] for raw in (data.get("products") or [])[:3]: item = _sanitize(raw, urls) if item: out.append(item) return out def lookup(query, force=False): """搜索 + 抽取,返回新增的联网商品。失败时返回 []。""" query = (query or "").strip() if not query: return [] now = time.time() with _lock: hit = _CACHE.get(query) if hit and not force and now - hit[0] < _TTL: return hit[1] results, _provider = search(query) products = extract_products(query, results) if results else [] added = _register(products) with _lock: _CACHE[query] = (now, added) return added def stats(): key, _, _, ws = _conf() return { "enabled": True, "provider": ws.get("provider") or "auto(so360→bing)", "keyConfigured": bool(ws.get("apiKey") or key), "cachedProducts": len(all_products()), "cacheSize": len(_CACHE), } ``` --- ## xlsx_export.py ```python # -*- coding: utf-8 -*- """纯标准库生成 Excel(.xlsx)对比表格,供用户下载到本地用 Excel / WPS 打开。 不依赖第三方库,直接按 OOXML 规范打包 zip。 """ import os import re import time import zipfile XLSX_CONTENT_TYPES = """ """ XLSX_ROOT_RELS = """ """ XLSX_WORKBOOK = """ """ XLSX_WORKBOOK_RELS = """ """ XLSX_STYLES = """ """ def col_letter(index): """1 -> A, 27 -> AA""" letters = "" while index: index, rem = divmod(index - 1, 26) letters = chr(65 + rem) + letters return letters def _xml_escape(value): return (str(value) .replace("&", "&").replace("<", "<").replace(">", ">") .replace('"', """)) def _cell(ref, value, style): if isinstance(value, (int, float)) and not isinstance(value, bool): return f'{value}' text = _xml_escape(value).replace("\n", " ") return f'{text}' def _sheet_xml(model): headers = model.get("headers") or [] rows = model.get("rows") or [] notes = model.get("notes") or [] title = model.get("title") or "商品对比表" width = max(len(headers), max((len(r) for r in rows), default=0)) width = max(width, 2) lines = [] # 标题 lines.append( '' + _cell(f"A1", title, 3) + "" ) # 表头 r = 2 if headers: cells = "".join(_cell(f"{col_letter(i + 1)}{r}", h, 1) for i, h in enumerate(headers)) lines.append(f'{cells}') r += 1 # 数据行 for row in rows: cells = [] for i, value in enumerate(row): cells.append(_cell(f"{col_letter(i + 1)}{r}", value, 2)) lines.append(f'{"".join(cells)}') r += 1 # 结论文本 if notes: r += 1 lines.append(f'{_cell(f"A{r}", "选购结论", 3)}') r += 1 for note in notes: lines.append( f'' + _cell(f"A{r}", note, 4) + "" ) r += 1 r += 1 lines.append( f'{_cell(f"A{r}", "数据说明:价格与参数来自商品页/站内公开标价,实际以下单页面为准;缺失项已标注【暂无公开参数】。", 4)}' ) cols = "" if width: widths = [16, 12] + [30] * (width - 2) cols = "" + "".join( f'' for i in range(width) ) + "" return ( '' '' '' '' '' f"{cols}{''.join(lines)}" '' "" ) def write_xlsx(model, path): """把 {title, headers, rows, notes} 写成 xlsx。""" os.makedirs(os.path.dirname(path), exist_ok=True) with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("[Content_Types].xml", XLSX_CONTENT_TYPES) zf.writestr("_rels/.rels", XLSX_ROOT_RELS) zf.writestr("xl/workbook.xml", XLSX_WORKBOOK) zf.writestr("xl/_rels/workbook.xml.rels", XLSX_WORKBOOK_RELS) zf.writestr("xl/styles.xml", XLSX_STYLES) zf.writestr("xl/worksheets/sheet1.xml", _sheet_xml(model)) return path def safe_filename(name, fallback="商品对比表.xlsx"): name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", (name or "").strip()) or fallback if not name.lower().endswith(".xlsx"): name += ".xlsx" return name[:80] def new_filename(): return time.strftime("compare_%Y%m%d_%H%M%S_") + os.urandom(4).hex() + ".xlsx" ``` --- ## tools/build_catalog.py ```python # -*- coding: utf-8 -*- """从 data/products.json(手机)+ 内置多品类清单生成 data/catalog.json。 只用于初始化/重建商品目录;生成后的商品以数据库为准。 """ import json import os BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DATA_DIR = os.path.join(BASE_DIR, "data") PHONE_SPECS = [("chip", "处理器"), ("screen", "屏幕"), ("battery", "电池"), ("charge", "快充"), ("camera", "影像"), ("weight", "重量"), ("storage", "存储"), ("system", "系统")] BRAND_TIERS = { "苹果": "luxury", "华为": "luxury", "三星": "luxury", "索尼": "luxury", "Bose": "luxury", "戴森": "luxury", "SK-II": "luxury", "兰蔻": "luxury", "始祖鸟": "luxury", "Coach": "luxury", "小米": "popular", "OPPO": "popular", "vivo": "popular", "荣耀": "popular", "联想": "popular", "戴尔": "popular", "美的": "popular", "格力": "popular", "海尔": "popular", "石头": "popular", "安踏": "popular", "李宁": "popular", "珀莱雅": "popular", "三只松鼠": "popular", "元气森林": "popular", "伊利": "popular", "林氏家居": "popular", "源氏木语": "popular", "宜家": "popular", "红米": "economy", "realme": "economy", "机械革命": "economy", "名创优品": "economy", "白象": "economy", "优衣库": "economy", "南极人": "economy", "九阳": "economy", "小米生态链": "economy", "一加": "niche", "魅族": "niche", "韶音": "niche", "Anker": "niche", "徕芬": "niche", "观夏": "niche", "内外": "niche", "猫王": "niche", "努比亚": "niche", "佳明": "niche", "花西子": "niche", "ROG": "niche", } BRAND_COLORS = { "苹果": ("#3a3f4b", "#7d8497"), "华为": ("#c8102e", "#ff6b7f"), "小米": ("#ff6900", "#ffb26b"), "红米": ("#f04a23", "#ff8f6b"), "荣耀": ("#0aa1e0", "#69d0ff"), "OPPO": ("#0f8f5f", "#5cd3a4"), "vivo": ("#2b6cff", "#7ea6ff"), "一加": ("#e02a2a", "#ff7b7b"), "三星": ("#1f4cbf", "#6f93ff"), "联想": ("#1f56c4", "#6d94e8"), "戴尔": ("#0b6bbd", "#63a6de"), "ROG": ("#c8102e", "#ff6b7f"), "戴森": ("#8b2fbf", "#c47ce8"), "美的": ("#1f8ad6", "#74bdf0"), "格力": ("#0e63a8", "#5ea3d8"), "SK-II": ("#b8860b", "#e3c26b"), "兰蔻": ("#c2185b", "#ff7aa8"), "始祖鸟": ("#1c3d2b", "#5f8d76"), "安踏": ("#d81e06", "#ff6b57"), "优衣库": ("#e60012", "#ff6b6b"), "观夏": ("#5a4b3c", "#a3917f"), "三只松鼠": ("#c98a2b", "#e8c07a"), "元气森林": ("#2bb673", "#7ce0ad"), } DEFAULT_ENVS = ["indoor", "outdoor"] def phone_items(): with open(os.path.join(DATA_DIR, "products.json"), "r", encoding="utf-8") as f: phones = json.load(f) items = [] for p in phones: specs = {} for key, label in PHONE_SPECS: specs[label] = p.get(key) or "暂无" items.append({ "id": p["id"], "category": "手机", "brand": p["brand"], "brandTier": BRAND_TIERS.get(p["brand"], "popular"), "name": p["name"], "price": p["price"], "rating": p["rating"], "sales": p["sales"], "tags": p["tags"], "highlights": p["highlights"], "audience": p["audience"], "scenes": p["scenes"], "environments": ["indoor", "outdoor"] if "商务" in p["scenes"] or "续航" in p["scenes"] else ["indoor"], "specs": specs, "color": p.get("color") or BRAND_COLORS.get(p["brand"], ("#5b6b8c", "#9aa8c2"))[0], "color2": p.get("color2") or BRAND_COLORS.get(p["brand"], ("#5b6b8c", "#9aa8c2"))[1], "origin": "seed", }) return items # (id, 品类, 品牌, 名称, 价格, 好评, 销量, 标签, 亮点, 人群, 场景, 环境, 参数) NEW_ITEMS = [ # ---------------- 笔记本 ("mbp-air", "笔记本", "苹果", "MacBook Air M3 13英寸", 7999, 98, 52000, ["轻薄", "办公", "长续航"], ["M3 芯片,18 小时续航", "1.24kg,无风扇静音"], "移动办公与设计人群", ["办公", "轻薄", "续航"], ["indoor"], {"处理器": "Apple M3", "屏幕": "13.6 英寸 Liquid 视网膜", "重量": "1.24kg", "续航": "约 18 小时"}), ("lenovo-xiaoxin", "笔记本", "联想", "小新 Pro 16 2025", 5499, 95, 86000, ["性能", "大屏", "性价比"], ["2.5K 165Hz 高刷屏", "锐龙 7 处理器,整机 1.89kg"], "学生与全能办公", ["办公", "游戏", "性能"], ["indoor"], {"处理器": "锐龙 7 8845H", "屏幕": "16 英寸 2.5K 165Hz", "重量": "1.89kg", "续航": "约 8 小时"}), ("matebook-xpro", "笔记本", "华为", "MateBook X Pro 2025", 9999, 97, 21000, ["轻薄", "商务", "高奢"], ["1.0kg 超轻机身", "3.1K OLED 触控屏"], "高端商务人士", ["办公", "轻薄", "商务"], ["indoor"], {"处理器": "酷睿 Ultra 7", "屏幕": "14.2 英寸 3.1K OLED", "重量": "1.0kg", "续航": "约 10 小时"}), ("dell-xps13", "笔记本", "戴尔", "XPS 13 Plus", 8999, 94, 18000, ["轻薄", "商务"], ["无边框键盘设计", "13 代酷睿 + 2.8K 屏"], "商务出差人群", ["办公", "轻薄"], ["indoor"], {"处理器": "酷睿 i7-1360P", "屏幕": "13.4 英寸 2.8K", "重量": "1.26kg", "续航": "约 9 小时"}), ("rog-16", "笔记本", "ROG", "幻 16 星空版", 13999, 96, 9600, ["游戏", "性能", "大屏"], ["RTX 4070 独显", "2.5K 240Hz 电竞屏"], "硬核游戏玩家", ["游戏", "性能"], ["indoor"], {"处理器": "酷睿 i9-14900HX", "显卡": "RTX 4070", "屏幕": "16 英寸 2.5K 240Hz", "重量": "1.9kg"}), ("mec-rev", "笔记本", "机械革命", "极光 X 15", 4299, 91, 33000, ["性价比", "游戏"], ["RTX 4060 独显", "144Hz 高刷"], "预算有限的游戏用户", ["游戏", "性价比"], ["indoor"], {"处理器": "酷睿 i7-13620H", "显卡": "RTX 4060", "屏幕": "15.6 英寸 144Hz", "重量": "2.1kg"}), # ---------------- 平板 ("ipad-air", "平板", "苹果", "iPad Air M2 11英寸", 4799, 98, 64000, ["大屏", "办公", "系统"], ["M2 芯片,支持二代笔", "10.9 英寸全面屏"], "学生与轻办公", ["办公", "大屏", "系统"], ["indoor"], {"处理器": "Apple M2", "屏幕": "11 英寸全面屏", "重量": "462g", "续航": "约 10 小时"}), ("mi-pad7", "平板", "小米", "小米平板 7 Pro", 2499, 96, 43000, ["大屏", "性价比", "办公"], ["3.2K 144Hz 屏", "骁龙 8s Gen3"], "影音与网课用户", ["大屏", "办公", "性价比"], ["indoor"], {"处理器": "骁龙 8s Gen3", "屏幕": "11.2 英寸 3.2K 144Hz", "重量": "500g", "续航": "约 9 小时"}), ("matepad-pro", "平板", "华为", "MatePad Pro 13.2", 5299, 97, 15000, ["大屏", "办公", "商务"], ["13.2 英寸 OLED", "星闪手写笔"], "专业办公与绘画", ["办公", "大屏", "商务"], ["indoor"], {"处理器": "麒麟 9000s", "屏幕": "13.2 英寸 OLED", "重量": "580g", "续航": "约 8 小时"}), ("honor-pad", "平板", "荣耀", "荣耀平板 V9", 1599, 94, 29000, ["性价比", "大屏"], ["12.1 英寸 2.5K 护眼屏", "八扬声器"], "学生网课", ["大屏", "性价比", "学生"], ["indoor"], {"处理器": "骁龙 7 Gen3", "屏幕": "12.1 英寸 2.5K", "重量": "555g", "续航": "约 10 小时"}), # ---------------- 耳机 ("airpods-pro", "耳机", "苹果", "AirPods Pro 2", 1899, 98, 210000, ["降噪", "通勤", "系统"], ["自适应降噪", "空间音频"], "通勤与苹果生态用户", ["通勤", "音乐", "降噪"], ["indoor", "outdoor"], {"类型": "入耳式", "降噪": "自适应主动降噪", "续航": "6+30 小时", "重量": "5.3g/只"}), ("sony-xm5", "耳机", "索尼", "WF-1000XM5", 1999, 97, 76000, ["降噪", "音质"], ["业界顶级降噪", "LDAC 高解析音质"], "对音质挑剔的用户", ["音乐", "降噪", "通勤"], ["indoor", "outdoor"], {"类型": "入耳式", "降噪": "主动降噪", "续航": "8+24 小时", "重量": "5.9g/只"}), ("shokz-openrun", "耳机", "韶音", "OpenRun Pro 2", 1298, 96, 58000, ["运动", "户外", "骨传导"], ["不入耳佩戴,跑步稳固", "IP55 防水"], "跑步与骑行爱好者", ["运动", "户外"], ["outdoor"], {"类型": "骨传导", "防水": "IP55", "续航": "12 小时", "重量": "29g"}), ("edifier-lolli", "耳机", "漫步者", "LolliPods Pro 3", 299, 93, 320000, ["性价比", "降噪"], ["-42dB 主动降噪", "百元价位蓝牙 5.3"], "学生党与性价比用户", ["音乐", "降噪", "性价比"], ["indoor", "outdoor"], {"类型": "入耳式", "降噪": "-42dB", "续航": "5+20 小时", "重量": "4.5g/只"}), ("bose-ultra", "耳机", "Bose", "QuietComfort Ultra", 2799, 97, 24000, ["高奢", "降噪", "音质"], ["沉浸式空间音频", "佩戴极舒适"], "高端通勤人群", ["音乐", "降噪", "商务"], ["indoor", "outdoor"], {"类型": "头戴式", "降噪": "自适应", "续航": "24 小时", "重量": "250g"}), # ---------------- 穿戴 ("watch-s10", "智能穿戴", "苹果", "Apple Watch Series 10", 2999, 98, 88000, ["健康", "系统", "户外"], ["全天候血氧心率监测", "运动圆环"], "健康管理与运动人群", ["健康", "运动", "系统"], ["indoor", "outdoor"], {"屏幕": "1.96 英寸 OLED", "健康监测": "心率/血氧/睡眠", "续航": "18 小时", "防水": "50 米"}), ("watch-gt5", "智能穿戴", "华为", "华为 Watch GT5", 1488, 96, 120000, ["续航", "健康", "户外"], ["14 天超长续航", "越野跑模式"], "长续航运动用户", ["健康", "运动", "续航"], ["indoor", "outdoor"], {"屏幕": "1.43 英寸 AMOLED", "健康监测": "心率/血氧/压力", "续航": "14 天", "重量": "35g"}), ("mi-band9", "智能穿戴", "小米", "小米手环 9", 249, 95, 460000, ["性价比", "健康"], ["21 天续航", "150+ 运动模式"], "入门健康监测", ["健康", "性价比"], ["indoor", "outdoor"], {"屏幕": "1.62 英寸 AMOLED", "健康监测": "心率/睡眠", "续航": "21 天", "重量": "15g"}), ("garmin-265", "智能穿戴", "佳明", "Forerunner 265", 2680, 97, 13000, ["户外", "运动", "小众"], ["专业跑步训练建议", "多频 GPS"], "严肃跑者与越野爱好者", ["运动", "户外"], ["outdoor"], {"屏幕": "1.3 英寸 AMOLED", "GPS": "多频多星", "续航": "13 天", "重量": "47g"}), # ---------------- 家电 ("dyson-hd15", "家电", "戴森", "戴森 Supersonic HD15 吹风机", 3399, 97, 62000, ["高奢", "个护", "居家"], ["智能温控不伤发", "五种风嘴"], "追求护发体验的用户", ["居家", "个护"], ["indoor"], {"类型": "高速吹风机", "功率": "1600W", "风速": "13 万转/分钟", "重量": "560g"}), ("midea-ac", "家电", "美的", "美的 1.5 匹新一级变频空调", 2699, 95, 180000, ["居家", "省电", "热门"], ["新一级能效", "无声睡眠模式"], "卧室与客厅装机", ["居家", "省电"], ["indoor"], {"类型": "壁挂式", "匹数": "1.5 匹", "能效": "新一级", "适用面积": "15-23㎡"}), ("gree-ac", "家电", "格力", "格力 云佳 1.5 匹空调", 2899, 96, 210000, ["居家", "热门", "耐用"], ["双级增焓压缩机", "自清洁"], "看重耐用与售后的家庭", ["居家", "耐用"], ["indoor"], {"类型": "壁挂式", "匹数": "1.5 匹", "能效": "一级", "适用面积": "16-20㎡"}), ("haier-fridge", "家电", "海尔", "海尔 501L 十字对开门冰箱", 4299, 96, 96000, ["居家", "大容量", "热门"], ["干湿分储", "一级能效"], "三口以上家庭", ["居家", "大容量"], ["indoor"], {"类型": "十字对开门", "容量": "501L", "能效": "一级", "尺寸": "830×1900mm"}), ("roborock-s8", "家电", "石头", "石头 P10S Pro 扫地机器人", 3299, 94, 74000, ["居家", "智能", "热门"], ["双机械臂贴边清扫", "自动集尘"], "养宠物与上班族", ["居家", "智能"], ["indoor"], {"类型": "扫拖一体", "吸力": "11000Pa", "水箱": "自动补水", "适用面积": "120㎡"}), ("joyoung-soy", "家电", "九阳", "九阳破壁豆浆机", 399, 93, 150000, ["居家", "性价比", "厨房"], ["免滤免煮", "一键清洗"], "早餐党与家庭厨房", ["居家", "厨房", "性价比"], ["indoor"], {"类型": "破壁机", "容量": "1.2L", "功率": "800W", "功能": "豆浆/米糊/果汁"}), # ---------------- 美妆 ("sk2-facial", "美妆", "SK-II", "SK-II 神仙水 230ml", 1540, 97, 88000, ["高奢", "护肤", "口碑"], ["PITERA 精华", "稳定肤况"], "轻熟龄护肤人群", ["护肤", "高奢"], ["indoor"], {"容量": "230ml", "适合肤质": "混合/油皮", "核心成分": "PITERA", "质地": "水状精华"}), ("lancome-serum", "美妆", "兰蔻", "兰蔻小黑瓶精华 50ml", 1080, 96, 120000, ["高奢", "护肤", "抗初老"], ["二裂酵母发酵物", "修护屏障"], "25+ 抗初老", ["护肤", "抗初老"], ["indoor"], {"容量": "50ml", "适合肤质": "全肤质", "核心成分": "二裂酵母", "质地": "蛋清精华"}), ("proya-serum", "美妆", "珀莱雅", "珀莱雅红宝石精华 2.0", 249, 95, 260000, ["护肤", "性价比", "抗老"], ["六胜肽抗皱", "国货口碑爆款"], "学生与初抗老人群", ["护肤", "性价比", "抗老"], ["indoor"], {"容量": "30ml", "适合肤质": "混合/干皮", "核心成分": "六胜肽", "质地": "轻盈精华"}), ("huaxizi-powder", "美妆", "花西子", "花西子空气蜜粉", 169, 93, 98000, ["彩妆", "国风", "小众"], ["控油定妆", "国风雕花包装"], "喜欢国风彩妆的年轻女生", ["彩妆", "定妆"], ["indoor", "outdoor"], {"容量": "8.5g", "适合肤质": "油皮/混油", "妆效": "哑光控油", "色号": "自然色"}), ("perfect-diary", "美妆", "完美日记", "完美日记小细跟口红", 89, 91, 210000, ["彩妆", "性价比"], ["丝绒质地", "显白显气色"], "学生党与入门彩妆", ["彩妆", "性价比"], ["indoor", "outdoor"], {"容量": "3.5g", "质地": "丝绒哑光", "色号": "多色可选", "持妆": "约 6 小时"}), # ---------------- 食品 ("squirrel-nuts", "食品", "三只松鼠", "三只松鼠每日坚果 750g", 129, 95, 320000, ["零食", "健康", "热门"], ["30 小袋独立装", "六种坚果果干"], "上班族加餐", ["零食", "健康"], ["indoor", "outdoor"], {"规格": "750g/30 袋", "口味": "原味混合", "保质期": "180 天", "储存": "阴凉干燥"}), ("baixiang-noodle", "食品", "白象", "白象汤好喝老母鸡汤面", 39.9, 96, 560000, ["速食", "性价比", "国货"], ["真鸡汤底", "非油炸面饼"], "宿舍与加班速食", ["速食", "性价比"], ["indoor"], {"规格": "5 连包", "口味": "老母鸡汤", "保质期": "6 个月", "做法": "煮/泡"}), ("genki-soda", "食品", "元气森林", "元气森林白桃味气泡水", 55, 92, 680000, ["饮料", "零糖", "热门"], ["0 糖 0 脂 0 卡", "白桃清爽口味"], "控糖与年轻人群", ["饮料", "零糖"], ["indoor", "outdoor"], {"规格": "480ml×15 瓶", "口味": "白桃", "保质期": "9 个月", "糖分": "0 糖"}), ("wuchang-rice", "食品", "五常", "五常稻花香大米 5kg", 89, 97, 290000, ["主食", "性价比", "家庭"], ["当季新米", "稻花香 2 号"], "家庭日常主食", ["主食", "家庭"], ["indoor"], {"规格": "5kg", "产地": "黑龙江五常", "品种": "稻花香 2 号", "保质期": "12 个月"}), ("yili-milk", "食品", "伊利", "伊利金典有机纯牛奶", 79, 96, 410000, ["乳品", "健康", "礼盒"], ["有机认证", "3.8g 乳蛋白"], "家庭早餐与送礼", ["乳品", "健康"], ["indoor", "outdoor"], {"规格": "250ml×12 盒", "蛋白质": "3.8g/100ml", "保质期": "6 个月", "储存": "常温"}), # ---------------- 服饰 ("arcteryx-jacket", "服饰", "始祖鸟", "始祖鸟 Beta LT 冲锋衣", 4500, 98, 28000, ["高奢", "户外", "防风"], ["GORE-TEX 面料", "轻量三层压胶"], "户外徒步与通勤", ["户外", "防风", "高奢"], ["outdoor"], {"材质": "GORE-TEX 三层", "防水": "20000mm", "重量": "395g", "适用季节": "春秋冬"}), ("uniqlo-fleece", "服饰", "优衣库", "优衣库摇粒绒外套", 199, 94, 780000, ["性价比", "保暖", "居家"], ["轻便保暖", "多色可选"], "学生与通勤日常", ["保暖", "性价比"], ["indoor", "outdoor"], {"材质": "聚酯纤维摇粒绒", "适用季节": "秋冬", "尺码": "S-XXL", "版型": "宽松"}), ("anta-shoes", "服饰", "安踏", "安踏氢跑 5 跑步鞋", 299, 93, 520000, ["运动", "性价比", "热门"], ["轻量回弹中底", "透气网面"], "日常慢跑与健走", ["运动", "跑步", "性价比"], ["outdoor"], {"材质": "网面+EVA", "重量": "约 230g", "适用场景": "慢跑/健走", "尺码": "36-45"}), ("neiwai-bra", "服饰", "内外", "内外零感无尺码内衣", 169, 95, 190000, ["舒适", "小众", "贴身"], ["无钢圈零感", "无尺码适配"], "追求舒适的女性", ["舒适", "贴身"], ["indoor"], {"材质": "锦纶+氨纶", "版型": "无钢圈", "尺码": "均码", "适用季节": "四季"}), # ---------------- 家居 ("ikea-desk", "家居", "宜家", "宜家贝肯特书桌 120×60", 499, 92, 210000, ["性价比", "办公", "居家"], ["可调节桌腿", "简约白色"], "居家办公与学生", ["办公", "居家", "性价比"], ["indoor"], {"材质": "刨花板贴面", "尺寸": "120×60cm", "风格": "简约", "承重": "50kg"}), ("yuanshi-table", "家居", "源氏木语", "源氏木语实木餐桌 1.4m", 1899, 95, 86000, ["实木", "居家", "热门"], ["北美白橡木", "环保水性漆"], "注重环保的家庭", ["居家", "耐用"], ["indoor"], {"材质": "白橡木", "尺寸": "140×80cm", "风格": "北欧", "工艺": "榫卯"}), ("linshi-sofa", "家居", "林氏家居", "林氏家居科技布沙发 三人位", 2699, 93, 63000, ["居家", "舒适", "热门"], ["科技布防污", "高回弹海绵"], "小户型与租房改造", ["居家", "舒适"], ["indoor"], {"材质": "科技布", "尺寸": "200×90cm", "风格": "现代简约", "填充": "高回弹海绵"}), ("guanxia-candle", "家居", "观夏", "观夏昆仑煮雪香薰蜡烛", 328, 96, 42000, ["香薰", "小众", "礼赠"], ["雪松木质调", "手工浇筑"], "居家氛围与送礼", ["香薰", "礼赠", "居家"], ["indoor"], {"容量": "220g", "香调": "雪松木质", "燃烧时长": "约 45 小时", "材质": "大豆蜡"}), ] def build(): items = phone_items() for row in NEW_ITEMS: (pid, category, brand, name, price, rating, sales, tags, highlights, audience, scenes, envs, specs) = row color, color2 = BRAND_COLORS.get(brand, ("#5b6b8c", "#9aa8c2")) items.append({ "id": pid, "category": category, "brand": brand, "brandTier": BRAND_TIERS.get(brand, "popular"), "name": name, "price": price, "rating": rating, "sales": sales, "tags": tags, "highlights": highlights, "audience": audience, "scenes": scenes, "environments": envs, "specs": specs, "color": color, "color2": color2, "origin": "seed", }) with open(os.path.join(DATA_DIR, "catalog.json"), "w", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2) print("catalog:", len(items), "items |", len({i["category"] for i in items}), "categories") if __name__ == "__main__": build() ``` --- ## tools/gen_models.py ```python #!/usr/bin/env python3 """按商品品类程序化生成简化 GLB 3D 模型(纯 Python 标准库,零外部依赖)。 用法: python tools/gen_models.py # 生成全部缺失模型 python tools/gen_models.py --force # 覆盖已有模型 python tools/gen_models.py ip16 mi15 # 只生成指定商品 """ import json import math import os import struct import sys BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CATALOG_PATH = os.path.join(BASE, "data", "catalog.json") OUT_DIR = os.path.join(BASE, "web", "models") SCREEN = "#0a0d13" DARK = "#1c1f26" PANEL = "#2b303b" SILVER = "#c6cbd5" WHITE = "#eef1f6" GLASS = "#dfe8f3" WOOD = "#b98553" # ---------------------------------------------------------------- 颜色与网格 def hex2lin(value): h = (value or "#888888").lstrip("#") if len(h) == 3: h = "".join(ch * 2 for ch in h) rgb = [int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)] def lin(c): return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 return [lin(rgb[0]), lin(rgb[1]), lin(rgb[2]), 1.0] def new_mesh(): return {"v": [], "n": []} def tri(m, p0, p1, p2, n0, n1, n2): m["v"].extend(p0 + p1 + p2) m["n"].extend(n0 + n1 + n2) def quad(m, p0, p1, p2, p3, n0, n1, n2, n3): tri(m, p0, p1, p2, n0, n1, n2) tri(m, p0, p2, p3, n0, n2, n3) def merge(target, src): target["v"].extend(src["v"]) target["n"].extend(src["n"]) return target def _cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) def _norm(v): ln = math.sqrt(sum(x * x for x in v)) if ln < 1e-9: return (0, 0, 0) return (v[0] / ln, v[1] / ln, v[2] / ln) def _add(a, b): return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) def _sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) def _mul(a, k): return (a[0] * k, a[1] * k, a[2] * k) # ---------------------------------------------------------------- 基础几何 def box(cx, cy, cz, sx, sy, sz): """轴对齐长方体(中心点 + 三向尺寸)。""" m = new_mesh() hx, hy, hz = sx / 2.0, sy / 2.0, sz / 2.0 x0, x1 = cx - hx, cx + hx y0, y1 = cy - hy, cy + hy z0, z1 = cz - hz, cz + hz faces = [ ((1, 0, 0), (x1, y0, z0), (x1, y1, z0), (x1, y1, z1), (x1, y0, z1)), ((-1, 0, 0), (x0, y1, z0), (x0, y0, z0), (x0, y0, z1), (x0, y1, z1)), ((0, 1, 0), (x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)), ((0, -1, 0), (x0, y0, z1), (x1, y0, z1), (x1, y0, z0), (x0, y0, z0)), ((0, 0, 1), (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)), ((0, 0, -1), (x1, y0, z0), (x0, y0, z0), (x0, y1, z0), (x1, y1, z0)), ] for n, p0, p1, p2, p3 in faces: quad(m, p0, p1, p2, p3, n, n, n, n) return m def _signed_pow(v, m): c = math.cos(v) s = math.sin(v) return ((1 if c >= 0 else -1) * abs(c) ** m, (1 if s >= 0 else -1) * abs(s) ** m) def superellipsoid(cx, cy, cz, sx, sy, sz, e=0.25, nu=18, nv=36): """超椭球:e→1 近似球,e→0.2 近似圆角方块。sx/sy/sz 为半尺寸。""" m = new_mesh() center = (cx, cy, cz) def point(u, v): cu, su = _signed_pow(u, e) cv, sv = _signed_pow(v, e) return (cx + sx * cu * cv, cy + sy * su, cz + sz * cu * sv) def normal(u, v): d = 1e-4 pu = _sub(point(u + d, v), point(u - d, v)) pv = _sub(point(u, v + d), point(u, v - d)) n = _norm(_cross(pv, pu)) if sum(n) == 0: return _norm(_sub(point(u, v), center)) if sum(a * b for a, b in zip(n, _sub(point(u, v), center))) < 0: n = _mul(n, -1) return n u0, u1 = -math.pi / 2.0, math.pi / 2.0 for i in range(nu): ua = u0 + (u1 - u0) * i / nu ub = u0 + (u1 - u0) * (i + 1) / nu for j in range(nv): va = 2 * math.pi * j / nv vb = 2 * math.pi * (j + 1) / nv p00, p10 = point(ua, va), point(ub, va) p11, p01 = point(ub, vb), point(ua, vb) n00, n10 = normal(ua, va), normal(ub, va) n11, n01 = normal(ub, vb), normal(ua, vb) quad(m, p00, p10, p11, p01, n00, n10, n11, n01) return m def cylinder(radius, height, seg=36, cap=True, r_top=None): """局部坐标:底面位于 y=0,向 +y 延伸。""" r2 = radius if r_top is None else r_top m = new_mesh() ring = [] for j in range(seg): a = 2 * math.pi * j / seg ring.append((math.cos(a), math.sin(a))) for j in range(seg): a0, a1 = ring[j], ring[(j + 1) % seg] p0 = (radius * a0[0], 0.0, radius * a0[1]) p1 = (radius * a1[0], 0.0, radius * a1[1]) p2 = (r2 * a1[0], height, r2 * a1[1]) p3 = (r2 * a0[0], height, r2 * a0[1]) slope = (radius - r2) / max(height, 1e-6) n0 = _norm((a0[0], slope, a0[1])) n1 = _norm((a1[0], slope, a1[1])) quad(m, p0, p1, p2, p3, n0, n1, n1, n0) if cap: for sign, yy in ((1, 0.0), (-1, height)): rr = radius if sign == 1 else r2 n = (0, sign, 0) center = (0, yy, 0) for j in range(seg): a0, a1 = ring[j], ring[(j + 1) % seg] p0 = (rr * a0[0], yy, rr * a0[1]) p1 = (rr * a1[0], yy, rr * a1[1]) if sign == 1: tri(m, center, p1, p0, n, n, n) else: tri(m, center, p0, p1, n, n, n) return m def torus(major, minor, axis="z", arc=2 * math.pi, start=0.0, seg=48, rseg=16): """局部:环在 xy 平面,绕 z 轴。""" m = new_mesh() def point(t, p): ct, st = math.cos(t), math.sin(t) cp, sp = math.cos(p), math.sin(p) return ((major + minor * cp) * ct, (major + minor * cp) * st, minor * sp) def normal(t, p): ct, st = math.cos(t), math.sin(t) cp, sp = math.cos(p), math.sin(p) return (cp * ct, cp * st, sp) closed = abs(arc - 2 * math.pi) < 1e-6 steps = seg if closed else seg for i in range(steps): ta = start + arc * i / seg tb = start + arc * (i + 1) / seg for j in range(rseg): pa = 2 * math.pi * j / rseg pb = 2 * math.pi * (j + 1) / rseg p00, p10 = point(ta, pa), point(tb, pa) p11, p01 = point(tb, pb), point(ta, pb) n00, n10 = normal(ta, pa), normal(tb, pa) n11, n01 = normal(tb, pb), normal(ta, pb) quad(m, p00, p10, p11, p01, n00, n10, n11, n01) if not closed: for t, flip in ((start, -1), (start + arc, 1)): ct, st = math.cos(t), math.sin(t) n = _norm((flip * -st, flip * ct, 0)) center = (major * ct, major * st, 0) for j in range(rseg): pa = 2 * math.pi * j / rseg pb = 2 * math.pi * (j + 1) / rseg p0, p1 = point(t, pa), point(t, pb) ring_pts = [p0, p1] if flip == 1: tri(m, center, ring_pts[0], ring_pts[1], n, n, n) else: tri(m, center, ring_pts[1], ring_pts[0], n, n, n) return m def rot(m, rx=0.0, ry=0.0, rz=0.0): ax, ay, az = (math.radians(rx), math.radians(ry), math.radians(rz)) cx, sx = math.cos(ax), math.sin(ax) cy, sy = math.cos(ay), math.sin(ay) cz, sz = math.cos(az), math.sin(az) def apply(p): x, y, z = p y, z = y * cx + z * sx, -y * sx + z * cx x, z = x * cy + z * sy, -x * sy + z * cy x, y = x * cz - y * sz, x * sz + y * cz return (x, y, z) out = new_mesh() out["v"] = [apply(tuple(p)) for p in zip(m["v"][0::3], m["v"][1::3], m["v"][2::3])] flat = [] for p in out["v"]: flat.extend(p) out["v"] = flat out["n"] = [apply(tuple(p)) for p in zip(m["n"][0::3], m["n"][1::3], m["n"][2::3])] flat = [] for p in out["n"]: flat.extend(p) out["n"] = flat return out def move(m, dx=0.0, dy=0.0, dz=0.0): out = new_mesh() vs = list(zip(m["v"][0::3], m["v"][1::3], m["v"][2::3])) out["v"] = [c for p in vs for c in (p[0] + dx, p[1] + dy, p[2] + dz)] out["n"] = m["n"] return out def axis_mesh(m, axis): if axis == "y": return m if axis == "z": return rot(m, rx=90) if axis == "x": return rot(m, rz=-90) return m # ---------------------------------------------------------------- 材质收集 def emit(parts, mesh, color, metallic=0.0, roughness=0.75, emissive=None): parts.append({ "mesh": mesh, "color": hex2lin(color), "metallic": metallic, "roughness": roughness, "emissive": hex2lin(emissive) if emissive else None, }) def glass_screen(parts, m, color, glow=0.35): emit(parts, m, SCREEN, metallic=0.1, roughness=0.18, emissive=color if glow else None) # ---------------------------------------------------------------- 各品类构建 def build_phone(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main flip = p["id"] == "mixflip" w, h, d = (0.092, 0.098, 0.017) if flip else (0.074, 0.158, 0.0088) cy = h / 2.0 emit(parts, superellipsoid(0, cy, 0, w / 2, h / 2, d / 2, e=0.22, nu=14, nv=40), main, metallic=0.35, roughness=0.42) emit(parts, superellipsoid(0, cy, d / 2 + 0.0006, w / 2 - 0.0038, h / 2 - 0.005, 0.0009, e=0.16, nu=10, nv=32), SCREEN, metallic=0.05, roughness=0.2, emissive=acc if p["id"] in ("ip16", "mate70", "mi15") else None) # 摄像头模组 cam_y = h - 0.021 if not flip else h - 0.026 cam_x = -w / 4 if not flip else -w / 4 emit(parts, superellipsoid(cam_x, cam_y, d / 2 + 0.0012, 0.0175, 0.0175, 0.0016, e=0.2, nu=8, nv=20), acc, metallic=0.4, roughness=0.35) for k in range(3): ox = cam_x - 0.0055 + (k % 2) * 0.011 oy = cam_y + 0.0052 - (k // 2) * 0.0105 emit(parts, move(axis_mesh(cylinder(0.0042, 0.0014, seg=20), "z"), ox, oy, d / 2 + 0.0024), "#16181f", metallic=0.65, roughness=0.18) # 侧键 for side in (-1, 1): emit(parts, box(side * (w / 2 - 0.0004), cy + 0.012, 0, 0.0022, 0.026 if side < 0 else 0.018, 0.004), acc, metallic=0.5, roughness=0.4) return parts def build_laptop(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main w, d = 0.315, 0.215 emit(parts, superellipsoid(0, 0.006, 0, w / 2, 0.006, d / 2, e=0.16, nu=10, nv=28), main, metallic=0.55, roughness=0.35) # 键盘面 emit(parts, box(0, 0.0123, -0.012, 0.272, 0.0012, 0.108), PANEL, metallic=0.2, roughness=0.6) for row in range(5): for col in range(13): kx = -0.118 + col * 0.0196 kz = -0.052 + row * 0.0192 emit(parts, box(kx, 0.0134, kz, 0.0168, 0.0012, 0.0162), "#3a4050", metallic=0.1, roughness=0.65) emit(parts, box(0, 0.0126, 0.066, 0.09, 0.0009, 0.052), SILVER, metallic=0.35, roughness=0.35) # 屏幕(向上倾斜) screen = box(0, 0.108, 0, w, 0.205, 0.0058) face = box(0, 0.108, 0.0034, w - 0.024, 0.19, 0.0007) screen = rot(screen, rx=-18) face = rot(face, rx=-18) screen = move(screen, dz=-0.1) face = move(face, dz=-0.1) emit(parts, screen, main, metallic=0.5, roughness=0.4) emit(parts, face, SCREEN, metallic=0.05, roughness=0.16, emissive=acc if p["id"] in ("mbp-air", "rog-16") else None) return parts def build_tablet(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main w, h, d = 0.19, 0.28, 0.0068 cy = h / 2.0 body = superellipsoid(0, cy, 0, w / 2, h / 2, d / 2, e=0.18, nu=14, nv=36) body = rot(body, rx=-6) emit(parts, body, main, metallic=0.4, roughness=0.4) face = superellipsoid(0, cy, d / 2 + 0.0005, w / 2 - 0.006, h / 2 - 0.008, 0.0008, e=0.14, nu=10, nv=32) face = rot(face, rx=-6) emit(parts, face, SCREEN, metallic=0.05, roughness=0.18, emissive=acc) emit(parts, move(axis_mesh(cylinder(0.0045, 0.0012, seg=18), "z"), 0, h - 0.02, d / 2 + 0.0015), "#16181f", metallic=0.6, roughness=0.2) return parts def build_earbuds(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main # 充电盒 emit(parts, superellipsoid(0, 0.026, 0, 0.032, 0.026, 0.015, e=0.3, nu=12, nv=26), WHITE, metallic=0.05, roughness=0.35) emit(parts, box(0, 0.030, 0, 0.0605, 0.0012, 0.0265), "#c9cfda", metallic=0.1, roughness=0.5) emit(parts, move(axis_mesh(cylinder(0.0032, 0.0006, seg=14), "y"), 0, 0.0, 0.013), acc, metallic=0.2, roughness=0.3) # 两只耳机 for side in (-1, 1): bx = side * 0.055 emit(parts, superellipsoid(bx, 0.020, 0.0, 0.0095, 0.0085, 0.0085, e=0.7, nu=12, nv=18), main, metallic=0.05, roughness=0.35) stem = superellipsoid(0, 0, 0, 0.0042, 0.0145, 0.0048, e=0.5, nu=8, nv=14) stem = move(stem, bx, 0.013, 0.006) emit(parts, stem, main, metallic=0.05, roughness=0.35) return parts def build_headphones(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main band = torus(0.082, 0.010, axis="z", arc=math.pi, start=0.0, seg=40, rseg=12) band = move(band, 0, 0.135, 0) emit(parts, band, main, metallic=0.25, roughness=0.45) for side in (-1, 1): cup = axis_mesh(cylinder(0.033, 0.036, seg=30), "x") cup = move(cup, side * 0.085 - (0.018 if side > 0 else 0), 0.088, 0) emit(parts, cup, main, metallic=0.2, roughness=0.45) pad = axis_mesh(cylinder(0.028, 0.008, seg=26), "x") pad = move(pad, side * 0.085 + (0.018 if side < 0 else -0.018), 0.088, 0) emit(parts, pad, acc, metallic=0.05, roughness=0.7) return parts def build_neckband(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main arc = torus(0.082, 0.006, axis="z", arc=math.radians(230), start=math.radians(205), seg=44, rseg=10) arc = move(arc, 0, 0.09, 0) emit(parts, arc, main, metallic=0.3, roughness=0.45) for side in (-1, 1): emit(parts, superellipsoid(side * 0.062, 0.035, 0.0, 0.018, 0.024, 0.012, e=0.35, nu=10, nv=20), acc, metallic=0.15, roughness=0.5) emit(parts, superellipsoid(side * 0.030, 0.086, 0.0, 0.014, 0.015, 0.012, e=0.4, nu=10, nv=18), main, metallic=0.1, roughness=0.5) return parts def build_watch(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main cy, cz = 0.095, 0.018 emit(parts, superellipsoid(0, cy, cz, 0.023, 0.027, 0.0065, e=0.22, nu=12, nv=28), main, metallic=0.7, roughness=0.28) emit(parts, superellipsoid(0, cy, cz + 0.0071, 0.0185, 0.0225, 0.0008, e=0.18, nu=10, nv=26), SCREEN, metallic=0.05, roughness=0.16, emissive=acc) loop = torus(0.047, 0.0075, axis="x", arc=2 * math.pi, seg=44, rseg=10) loop = move(loop, 0, cy, cz - 0.033) emit(parts, loop, acc, metallic=0.05, roughness=0.7) crown = axis_mesh(cylinder(0.0045, 0.005, seg=16), "x") crown = move(crown, 0.0228, cy + 0.010, cz) emit(parts, crown, SILVER, metallic=0.85, roughness=0.25) return parts def build_band(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main cy, cz = 0.09, 0.018 emit(parts, superellipsoid(0, cy, cz, 0.017, 0.030, 0.0052, e=0.55, nu=12, nv=24), main, metallic=0.5, roughness=0.3) emit(parts, superellipsoid(0, cy, cz + 0.0057, 0.0135, 0.025, 0.0007, e=0.4, nu=10, nv=22), SCREEN, metallic=0.05, roughness=0.16, emissive=acc) loop = torus(0.050, 0.0062, axis="x", arc=2 * math.pi, seg=44, rseg=10) loop = move(loop, 0, cy, cz - 0.036) emit(parts, loop, acc, metallic=0.02, roughness=0.75) return parts def build_hairdryer(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main handle = axis_mesh(cylinder(0.0165, 0.115, seg=26, r_top=0.019), "y") emit(parts, handle, main, metallic=0.25, roughness=0.4) ring = torus(0.044, 0.019, axis="z", arc=2 * math.pi, seg=44, rseg=18) ring = move(ring, 0, 0.185, 0.0) emit(parts, ring, main, metallic=0.3, roughness=0.38) emit(parts, move(axis_mesh(cylinder(0.030, 0.012, seg=26), "z"), 0, 0.185, -0.012), acc, metallic=0.5, roughness=0.3) emit(parts, move(box(0, 0.145, 0, 0.030, 0.026, 0.028), 0, 0, 0), main, metallic=0.25, roughness=0.45) return parts def build_ac(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.14, 0, 0.46, 0.14, 0.105, e=0.16, nu=14, nv=28), main, metallic=0.1, roughness=0.4) emit(parts, box(0, 0.145, 0.108, 0.885, 0.245, 0.004), WHITE, metallic=0.05, roughness=0.35) emit(parts, box(0, 0.062, 0.112, 0.84, 0.042, 0.003), PANEL, metallic=0.1, roughness=0.6) for i in range(7): emit(parts, box(-0.35 + i * 0.117, 0.062, 0.115, 0.014, 0.030, 0.002), "#4a5262", metallic=0.3, roughness=0.4) emit(parts, move(box(0, 0, 0, 0.02, 0.012, 0.001), 0.40, 0.145, 0.112), acc, metallic=0.1, roughness=0.25) return parts def build_fridge(p): parts = [] main = p.get("color") or SILVER acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.9, 0, 0.30, 0.9, 0.33, e=0.12, nu=14, nv=24), main, metallic=0.55, roughness=0.35) for side in (-1, 1): emit(parts, box(side * 0.152, 1.22, 0.338, 0.292, 1.14, 0.013), main, metallic=0.55, roughness=0.32) emit(parts, box(side * 0.152, 0.31, 0.338, 0.292, 0.595, 0.013), acc, metallic=0.5, roughness=0.36) emit(parts, box(side * 0.038, 1.22, 0.346, 0.010, 0.62, 0.006), SILVER, metallic=0.9, roughness=0.2) emit(parts, box(0, 0.615, 0.345, 0.59, 0.008, 0.010), "#8b93a3", metallic=0.6, roughness=0.3) return parts def build_robot_vacuum(p): parts = [] main = p.get("color") or SILVER acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.039, 0, 0.168, 0.039, 0.168, e=0.28, nu=14, nv=40), main, metallic=0.15, roughness=0.42) emit(parts, move(axis_mesh(cylinder(0.032, 0.030, seg=26), "y"), 0, 0.070, 0.062), main, metallic=0.2, roughness=0.4) emit(parts, move(axis_mesh(cylinder(0.026, 0.002, seg=26), "y"), 0, 0.098, 0.062), SCREEN, metallic=0.3, roughness=0.2) emit(parts, move(axis_mesh(cylinder(0.052, 0.002, seg=36), "y"), 0, 0.077, -0.018), PANEL, metallic=0.2, roughness=0.4) bumper = torus(0.163, 0.011, axis="y", arc=math.pi, start=math.radians(-90), seg=40, rseg=10) bumper = move(bumper, 0, 0.03, 0) emit(parts, bumper, acc, metallic=0.1, roughness=0.55) return parts def build_soymilk(p): parts = [] main = p.get("color") or SILVER acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.062, 0, 0.108, 0.062, 0.098, e=0.2, nu=12, nv=24), main, metallic=0.2, roughness=0.4) jug = axis_mesh(cylinder(0.072, 0.205, seg=34, r_top=0.077), "y") jug = move(jug, 0, 0.10, 0) emit(parts, jug, GLASS, metallic=0.1, roughness=0.2) emit(parts, move(axis_mesh(cylinder(0.079, 0.016, seg=34), "y"), 0, 0.305, 0), acc, metallic=0.25, roughness=0.35) handle = torus(0.062, 0.009, axis="x", arc=math.radians(190), start=math.radians(-95), seg=30, rseg=10) handle = move(handle, 0.078, 0.20, 0) emit(parts, handle, main, metallic=0.2, roughness=0.5) emit(parts, box(0, 0.072, 0.100, 0.095, 0.028, 0.003), PANEL, metallic=0.1, roughness=0.5) emit(parts, box(0, 0.072, 0.103, 0.055, 0.014, 0.002), SCREEN, metallic=0.1, roughness=0.2, emissive=acc) return parts def _bottle_base(parts, color, radius, height, cap_color, cap_h=0.03, cap_r=None): body = axis_mesh(cylinder(radius, height, seg=32, cap=True), "y") emit(parts, body, color, metallic=0.15, roughness=0.3) shoulder = axis_mesh(cylinder(radius, 0.018, seg=32, r_top=radius * 0.55), "y") shoulder = move(shoulder, 0, height, 0) emit(parts, shoulder, color, metallic=0.15, roughness=0.3) neck = move(axis_mesh(cylinder(radius * 0.5, 0.012, seg=24), "y"), 0, height + 0.018, 0) emit(parts, neck, color, metallic=0.15, roughness=0.35) cr = cap_r or radius * 0.62 cap = move(axis_mesh(cylinder(cr, cap_h, seg=28, cap=True), "y"), 0, height + 0.028, 0) emit(parts, cap, cap_color, metallic=0.45, roughness=0.25) def build_lotion(p): parts = [] main = p.get("color") or "#b8860b" _bottle_base(parts, main, 0.035, 0.135, WHITE, cap_h=0.032) emit(parts, move(axis_mesh(cylinder(0.0354, 0.052, seg=32), "y"), 0, 0.045, 0), WHITE, metallic=0.05, roughness=0.35) return parts def build_dropper(p): parts = [] main = p.get("color") or "#c2185b" _bottle_base(parts, main, 0.026, 0.088, DARK, cap_h=0.052, cap_r=0.014) emit(parts, move(axis_mesh(cylinder(0.0263, 0.038, seg=30), "y"), 0, 0.028, 0), WHITE, metallic=0.05, roughness=0.4) return parts def build_serum(p): parts = [] main = p.get("color") or "#5b6b8c" _bottle_base(parts, main, 0.027, 0.098, "#d8b45a", cap_h=0.036, cap_r=0.019) emit(parts, move(axis_mesh(cylinder(0.0273, 0.044, seg=30), "y"), 0, 0.026, 0), "#c9505f", metallic=0.1, roughness=0.35) return parts def build_compact(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main emit(parts, move(axis_mesh(cylinder(0.042, 0.020, seg=36), "y"), 0, 0, 0), main, metallic=0.3, roughness=0.3) emit(parts, move(axis_mesh(cylinder(0.042, 0.014, seg=36, r_top=0.040), "y"), 0, 0.020, 0), main, metallic=0.35, roughness=0.28) emit(parts, move(axis_mesh(cylinder(0.0415, 0.003, seg=36), "y"), 0, 0.019, 0), acc, metallic=0.6, roughness=0.25) emit(parts, move(axis_mesh(cylinder(0.013, 0.0015, seg=24), "y"), 0, 0.034, 0), acc, metallic=0.5, roughness=0.3) return parts def build_lipstick(p): parts = [] main = p.get("color") or "#c2185b" acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.038, 0, 0.011, 0.038, 0.011, e=0.12, nu=10, nv=18), main, metallic=0.35, roughness=0.3) emit(parts, move(axis_mesh(cylinder(0.0105, 0.008, seg=26, r_top=0.0115), "y"), 0, 0.076, 0), SILVER, metallic=0.85, roughness=0.22) bullet = axis_mesh(cylinder(0.0072, 0.026, seg=24, r_top=0.0042), "y") bullet = rot(bullet, rx=14) bullet = move(bullet, 0.001, 0.083, 0) emit(parts, bullet, acc, metallic=0.05, roughness=0.35) return parts def build_pouch(p): parts = [] main = p.get("color") or "#c98a2b" acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.145, 0, 0.105, 0.145, 0.036, e=0.1, nu=12, nv=24), main, metallic=0.2, roughness=0.5) emit(parts, box(0, 0.148, 0.0375, 0.16, 0.17, 0.004), acc, metallic=0.1, roughness=0.45) emit(parts, box(0, 0.292, 0, 0.185, 0.016, 0.020), "#d8c08a", metallic=0.1, roughness=0.5) emit(parts, box(0, 0.300, 0, 0.155, 0.006, 0.010), acc, metallic=0.05, roughness=0.6) return parts def build_cup_noodle(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main cup = axis_mesh(cylinder(0.083, 0.108, seg=36, r_top=0.102), "y") emit(parts, cup, main, metallic=0.05, roughness=0.4) emit(parts, move(axis_mesh(cylinder(0.100, 0.042, seg=36, r_top=0.099), "y"), 0, 0.035, 0), acc, metallic=0.05, roughness=0.4) emit(parts, move(axis_mesh(cylinder(0.107, 0.010, seg=36), "y"), 0, 0.108, 0), WHITE, metallic=0.1, roughness=0.35) emit(parts, move(axis_mesh(cylinder(0.017, 0.002, seg=20), "y"), 0, 0.118, 0.0), acc, metallic=0.1, roughness=0.3) return parts def build_can(p): parts = [] main = p.get("color") or "#2bb673" acc = p.get("color2") or main emit(parts, move(axis_mesh(cylinder(0.033, 0.112, seg=34), "y"), 0, 0.006, 0), main, metallic=0.55, roughness=0.28) emit(parts, move(axis_mesh(cylinder(0.0332, 0.062, seg=34), "y"), 0, 0.032, 0), WHITE, metallic=0.15, roughness=0.35) emit(parts, move(axis_mesh(cylinder(0.027, 0.008, seg=28, r_top=0.030), "y"), 0, 0.118, 0), SILVER, metallic=0.85, roughness=0.2) emit(parts, move(axis_mesh(cylinder(0.030, 0.004, seg=30), "y"), 0, 0.0, 0), SILVER, metallic=0.8, roughness=0.25) emit(parts, move(axis_mesh(cylinder(0.030, 0.002, seg=30), "y"), 0, 0.126, 0), PANEL, metallic=0.6, roughness=0.3) return parts def build_rice_bag(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.21, 0, 0.13, 0.21, 0.062, e=0.12, nu=12, nv=24), main, metallic=0.05, roughness=0.5) emit(parts, box(0, 0.20, 0.064, 0.20, 0.24, 0.004), acc, metallic=0.05, roughness=0.45) emit(parts, box(0, 0.412, 0, 0.22, 0.014, 0.03), "#e2e5ea", metallic=0.05, roughness=0.5) return parts def build_carton(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main emit(parts, box(0, 0.085, 0, 0.092, 0.17, 0.086), main, metallic=0.05, roughness=0.35) emit(parts, box(0, 0.085, 0.0445, 0.070, 0.12, 0.003), acc, metallic=0.05, roughness=0.35) for side in (-1, 1): roof = box(side * 0.024, 0.178, 0, 0.050, 0.004, 0.088) roof = rot(roof, rz=side * 22) roof = move(roof, 0, 0.004 if side > 0 else 0.004, 0) emit(parts, roof, main, metallic=0.05, roughness=0.35) emit(parts, box(0, 0.196, 0, 0.010, 0.012, 0.088), acc, metallic=0.05, roughness=0.35) return parts def build_jacket(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main torso = superellipsoid(0, 0.30, 0, 0.132, 0.165, 0.078, e=0.28, nu=14, nv=28) emit(parts, torso, main, metallic=0.05, roughness=0.6) emit(parts, superellipsoid(0, 0.445, 0, 0.185, 0.062, 0.072, e=0.3, nu=12, nv=24), main, metallic=0.05, roughness=0.6) for side in (-1, 1): arm = superellipsoid(0, 0, 0, 0.038, 0.13, 0.040, e=0.45, nu=10, nv=18) arm = rot(arm, rz=side * 14) arm = move(arm, side * 0.205, 0.325, 0) emit(parts, arm, main, metallic=0.05, roughness=0.6) collar = torus(0.052, 0.020, axis="y", arc=2 * math.pi, seg=36, rseg=12) collar = move(collar, 0, 0.472, 0) emit(parts, collar, acc, metallic=0.05, roughness=0.65) emit(parts, box(0, 0.30, 0.079, 0.007, 0.30, 0.003), DARK, metallic=0.2, roughness=0.4) return parts def build_shoe(p): parts = [] main = p.get("color") or DARK acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.017, 0, 0.150, 0.017, 0.052, e=0.2, nu=10, nv=26), WHITE, metallic=0.05, roughness=0.5) emit(parts, superellipsoid(-0.008, 0.056, 0, 0.128, 0.042, 0.045, e=0.5, nu=12, nv=26), main, metallic=0.05, roughness=0.5) emit(parts, superellipsoid(-0.108, 0.045, 0, 0.045, 0.030, 0.043, e=0.55, nu=10, nv=18), main, metallic=0.05, roughness=0.5) emit(parts, superellipsoid(0.118, 0.055, 0, 0.032, 0.048, 0.044, e=0.45, nu=10, nv=18), acc, metallic=0.05, roughness=0.5) for i in range(4): lx = -0.052 + i * 0.023 lace = box(lx, 0.088, 0, 0.006, 0.003, 0.060) emit(parts, lace, WHITE, metallic=0.05, roughness=0.45) emit(parts, box(-0.030, 0.030, 0, 0.140, 0.006, 0.010), acc, metallic=0.1, roughness=0.4) return parts def build_bra(p): parts = [] main = p.get("color") or "#e8c3c8" acc = p.get("color2") or main for side in (-1, 1): emit(parts, superellipsoid(side * 0.055, 0.155, 0, 0.056, 0.052, 0.033, e=0.85, nu=14, nv=24), main, metallic=0.02, roughness=0.65) strap = box(0, 0, 0, 0.018, 0.135, 0.008) strap = rot(strap, rz=side * 12) strap = move(strap, side * 0.105, 0.255, 0) emit(parts, strap, main, metallic=0.02, roughness=0.7) emit(parts, box(0, 0.098, 0, 0.235, 0.024, 0.016), acc, metallic=0.05, roughness=0.6) emit(parts, box(0, 0.156, 0.031, 0.075, 0.010, 0.004), acc, metallic=0.2, roughness=0.4) return parts def build_desk(p): parts = [] main = p.get("color") or WHITE acc = p.get("color2") or main emit(parts, box(0, 0.735, 0, 1.20, 0.030, 0.60), main, metallic=0.1, roughness=0.35) for sx in (-1, 1): for sz in (-1, 1): leg = axis_mesh(cylinder(0.021, 0.720, seg=18), "y") leg = move(leg, sx * 0.552, 0, sz * 0.255) emit(parts, leg, acc, metallic=0.7, roughness=0.3) emit(parts, box(0, 0.72, -0.24, 1.10, 0.05, 0.03), acc, metallic=0.4, roughness=0.35) return parts def build_table(p): parts = [] main = p.get("color") or WOOD acc = p.get("color2") or main emit(parts, superellipsoid(0, 0.755, 0, 0.70, 0.024, 0.425, e=0.18, nu=10, nv=22), main, metallic=0.05, roughness=0.45) for sx in (-1, 1): for sz in (-1, 1): leg = box(0, 0, 0, 0.055, 0.72, 0.055) leg = rot(leg, rx=sz * 5, rz=sx * 5) leg = move(leg, sx * 0.61, 0.36, sz * 0.35) emit(parts, leg, acc, metallic=0.05, roughness=0.5) emit(parts, box(0, 0.66, 0, 1.25, 0.022, 0.10), acc, metallic=0.05, roughness=0.5) return parts def build_candle(p): parts = [] main = p.get("color") or "#5a4b3c" acc = p.get("color2") or main emit(parts, move(axis_mesh(cylinder(0.050, 0.095, seg=34), "y"), 0, 0, 0), acc, metallic=0.05, roughness=0.2) emit(parts, move(axis_mesh(cylinder(0.0455, 0.078, seg=30), "y"), 0, 0.010, 0), "#efe6d5", metallic=0.0, roughness=0.6) emit(parts, move(axis_mesh(cylinder(0.0022, 0.014, seg=12), "y"), 0, 0.086, 0), DARK, metallic=0.0, roughness=0.8) emit(parts, move(axis_mesh(cylinder(0.0502, 0.030, seg=34), "y"), 0, 0.038, 0), main, metallic=0.1, roughness=0.35) return parts # ---------------------------------------------------------------- 品类路由 BUILDERS = { "手机": build_phone, "笔记本": build_laptop, "平板": build_tablet, "耳机": build_earbuds, "智能穿戴": build_watch, "家电": build_ac, "美妆": build_lotion, "食品": build_pouch, "服饰": build_jacket, "家居": build_desk, } SPECIAL = { "mixflip": build_phone, "shokz-openrun": build_neckband, "bose-ultra": build_headphones, "mi-band9": build_band, "dyson-hd15": build_hairdryer, "midea-ac": build_ac, "gree-ac": build_ac, "haier-fridge": build_fridge, "roborock-s8": build_robot_vacuum, "joyoung-soy": build_soymilk, "sk2-facial": build_lotion, "lancome-serum": build_dropper, "proya-serum": build_serum, "huaxizi-powder": build_compact, "perfect-diary": build_lipstick, "squirrel-nuts": build_pouch, "baixiang-noodle": build_cup_noodle, "genki-soda": build_can, "wuchang-rice": build_rice_bag, "yili-milk": build_carton, "arcteryx-jacket": build_jacket, "uniqlo-fleece": build_jacket, "anta-shoes": build_shoe, "neiwai-bra": build_bra, "ikea-desk": build_desk, "yuanshi-table": build_table, "guanxia-candle": build_candle, } # ---------------------------------------------------------------- GLB 打包 def build_glb(parts, name): buffer = bytearray() buffer_views = [] accessors = [] primitives = [] materials = [] material_index = {} def align(): while len(buffer) % 4: buffer.append(0) for part in parts: mesh = part["mesh"] count = len(mesh["v"]) // 3 if count == 0: continue align() pos_off = len(buffer) pos = mesh["v"] buffer.extend(struct.pack("<%df" % len(pos), *pos)) buffer_views.append({"buffer": 0, "byteOffset": pos_off, "byteLength": len(pos) * 4, "target": 34962}) acc = len(accessors) xs, ys, zs = pos[0::3], pos[1::3], pos[2::3] accessors.append({ "bufferView": len(buffer_views) - 1, "componentType": 5126, "count": count, "type": "VEC3", "min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)], }) align() nrm_off = len(buffer) nn = mesh["n"] buffer.extend(struct.pack("<%df" % len(nn), *nn)) buffer_views.append({"buffer": 0, "byteOffset": nrm_off, "byteLength": len(nn) * 4, "target": 34962}) nacc = len(accessors) accessors.append({ "bufferView": len(buffer_views) - 1, "componentType": 5126, "count": len(nn) // 3, "type": "VEC3", }) key = (tuple(round(x, 6) for x in part["color"]), round(part["metallic"], 4), round(part["roughness"], 4), tuple(round(x, 6) for x in (part["emissive"] or ()))) if key not in material_index: mat = { "pbrMetallicRoughness": { "baseColorFactor": part["color"], "metallicFactor": part["metallic"], "roughnessFactor": part["roughness"], }, "doubleSided": True, } if part["emissive"]: mat["emissiveFactor"] = part["emissive"][:3] material_index[key] = len(materials) materials.append(mat) primitives.append({ "attributes": {"POSITION": acc, "NORMAL": nacc}, "material": material_index[key], "mode": 4, }) align() json_doc = { "asset": {"version": "2.0", "generator": "shop3d-gen"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "name": name}], "meshes": [{"primitives": primitives}], "materials": materials, "accessors": accessors, "bufferViews": buffer_views, "buffers": [{"byteLength": len(buffer)}], } json_bytes = json.dumps(json_doc, separators=(",", ":")).encode("utf-8") while len(json_bytes) % 4: json_bytes += b" " total = 12 + 8 + len(json_bytes) + 8 + len(buffer) out = bytearray() out.extend(struct.pack(" 小函智能导购 · 全品类选品助手

    小函智能导购

    手机 · 电脑 · 耳机 · 家电 · 美妆 · 食品,室内室外都能挑

    对话引擎 · 本地规则
    支持粤语 / 四川话 / 东北话等方言
    ``` --- ## web/css/style.css ```css :root { --bg: #f4f6fa; --surface: #ffffff; --surface-2: #f8f9fc; --ink: #15181f; --ink-2: #3d4453; --muted: #7b8497; --line: #e9ecf3; --accent: #ff6a00; --accent-soft: rgba(255, 106, 0, 0.1); --accent-2: #2f6bff; --accent-2-soft: rgba(47, 107, 255, 0.1); --ok: #0fa678; --radius: 18px; --shadow: 0 10px 30px rgba(18, 26, 44, 0.07); --shadow-lg: 0 20px 50px rgba(18, 26, 44, 0.14); } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; background: radial-gradient(900px 500px at 8% -10%, rgba(255, 106, 0, 0.1), transparent 60%), radial-gradient(900px 500px at 92% 0%, rgba(47, 107, 255, 0.1), transparent 60%), var(--bg); color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; } button, input, select { font-family: inherit; } button { cursor: pointer; } .hidden { display: none !important; } .app { height: 100vh; display: flex; flex-direction: column; } /* ---------------- 顶栏 ---------------- */ .topbar { display: flex; align-items: center; gap: 14px; padding: 14px 22px 12px; } .logo { width: 42px; height: 42px; border-radius: 13px; background: linear-gradient(140deg, #ff8a2b, #ff5f00 60%, #ff3d00); color: #fff; font-weight: 700; font-size: 20px; display: grid; place-items: center; box-shadow: 0 8px 18px rgba(255, 95, 0, 0.35); } .titles h1 { font-size: 18px; margin: 0; letter-spacing: .2px; } .titles p { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); } .top-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .pill { font-size: 12px; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; box-shadow: 0 2px 8px rgba(18,26,44,.04); } .icon-btn { border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); border-radius: 11px; padding: 8px 13px; font-size: 13px; transition: .18s; } .icon-btn:hover { border-color: #d6dbe6; transform: translateY(-1px); } .icon-btn.tiny { font-size: 12px; padding: 5px 10px; border-radius: 9px; } .icon-btn.active { background: var(--accent-soft); border-color: rgba(255,106,0,.35); color: var(--accent); } /* ---------------- 布局 ---------------- */ .layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 268px minmax(0, 1fr) 392px; gap: 16px; padding: 4px 22px 20px; } .side { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 2px; } .card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; } .card-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 10px; } .live { font-size: 10.5px; font-weight: 500; color: var(--ok); background: rgba(15,166,120,.1); border-radius: 999px; padding: 2px 7px; } .profile-card .summary { font-size: 13px; color: var(--ink-2); line-height: 1.6; margin: 0 0 10px; } .chips { display: flex; flex-wrap: wrap; gap: 6px; } .chip { font-size: 11.5px; padding: 4px 9px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--line); color: var(--ink-2); } .chip.hot { background: var(--accent-soft); border-color: rgba(255,106,0,.22); color: #c85100; } .chip.blue { background: var(--accent-2-soft); border-color: rgba(47,107,255,.2); color: #2255cc; } .chip.empty { color: var(--muted); border-style: dashed; background: transparent; } .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0 12px; } .metrics div { background: var(--surface-2); border-radius: 12px; padding: 9px 8px; text-align: center; } .metrics b { display: block; font-size: 17px; color: var(--ink); } .metrics span { font-size: 10.5px; color: var(--muted); } .switch { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--ink-2); line-height: 1.45; cursor: pointer; } .switch input { appearance: none; width: 34px; height: 19px; border-radius: 999px; background: #dfe3ec; position: relative; transition: .2s; flex: none; } .switch input::after { content: ""; position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; background: #fff; border-radius: 50%; transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.2); } .switch input:checked { background: var(--accent); } .switch input:checked::after { left: 17px; } .quick-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .quick { font-size: 12px; text-align: left; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink-2); border-radius: 10px; padding: 9px 10px; transition: .18s; } .quick:hover { border-color: rgba(255,106,0,.35); background: #fff7f1; color: #c85100; } /* ---------------- 按钮 ---------------- */ .btn { border: 1px solid transparent; border-radius: 12px; font-size: 13px; padding: 10px 16px; transition: .18s; } .btn.primary { background: linear-gradient(135deg, #ff7d1f, #ff5f00); color: #fff; box-shadow: 0 8px 16px rgba(255,95,0,.24); } .btn.primary:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(255,95,0,.3); } .btn.primary:disabled { background: #e6e9f0; box-shadow: none; color: #a5adbd; cursor: not-allowed; transform: none; } .btn.ghost { background: var(--surface); border-color: var(--line); color: var(--ink-2); } .btn.ghost:hover { border-color: #d5dbe7; } .btn.small { padding: 7px 12px; font-size: 12px; border-radius: 10px; } /* ---------------- 聊天区 ---------------- */ .chat { display: flex; flex-direction: column; min-height: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 22px; box-shadow: var(--shadow); overflow: hidden; } .messages { flex: 1; overflow-y: auto; padding: 20px 22px 8px; display: flex; flex-direction: column; gap: 14px; } .msg { display: flex; gap: 10px; max-width: 100%; } .msg .avatar { width: 30px; height: 30px; border-radius: 9px; flex: none; background: linear-gradient(140deg, #ff8a2b, #ff5f00); color: #fff; display: grid; place-items: center; font-size: 13px; font-weight: 700; } .msg .body { max-width: min(680px, 92%); } .bubble { font-size: 13.5px; line-height: 1.72; padding: 12px 15px; border-radius: 14px; white-space: pre-wrap; word-break: break-word; } .msg.bot .bubble { background: var(--surface-2); border: 1px solid var(--line); border-top-left-radius: 4px; color: var(--ink-2); } .msg.user { justify-content: flex-end; } .msg.user .bubble { background: linear-gradient(135deg, #ff7d1f, #ff5f00); color: #fff; border-top-right-radius: 4px; box-shadow: 0 8px 18px rgba(255,95,0,.2); } .msg.user .avatar { display: none; } /* 导购回复里的 Markdown */ .msg .body.wide { max-width: min(940px, 96%); } .bubble.md { white-space: normal; } .bubble.md .md-p { margin: 6px 0; } .bubble.md .md-p:first-child { margin-top: 0; } .bubble.md strong { color: var(--ink); } .bubble.md .md-list { margin: 6px 0; padding-left: 18px; } .bubble.md .md-list li { margin: 3px 0; } .bubble.md .md-quote { margin-top: 10px; padding: 8px 10px; border-left: 3px solid rgba(255,106,0,.45); background: #fff8f3; border-radius: 0 8px 8px 0; font-size: 12px; color: var(--muted); } .bubble.md a { color: #c85100; word-break: break-all; } .md-table-wrap { margin: 8px 0; overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; } .md-table { border-collapse: collapse; width: 100%; min-width: 640px; font-size: 12px; background: var(--surface); } .md-table th, .md-table td { border-bottom: 1px solid var(--line); border-right: 1px solid var(--line); padding: 7px 9px; text-align: left; vertical-align: top; line-height: 1.55; } .md-table th { background: #fff4ec; color: #a84300; font-weight: 600; white-space: nowrap; } .md-table tbody tr:first-child td { background: #fffaf6; font-weight: 600; } .md-table tr:last-child td { border-bottom: none; } .md-table th:last-child, .md-table td:last-child { border-right: none; } .msg-cards { display: flex; gap: 10px; overflow-x: auto; padding: 10px 2px 4px; scrollbar-width: thin; } .msg-card { flex: none; width: 206px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 12px; box-shadow: 0 6px 16px rgba(18,26,44,.06); } .msg-card .mc-top { display: flex; align-items: center; gap: 10px; } .msg-card .mc-name { font-size: 12.5px; font-weight: 600; } .msg-card .mc-price { font-size: 15px; font-weight: 700; color: #e85d00; margin-top: 2px; } .msg-card .mc-meta { font-size: 10.5px; color: var(--muted); margin-top: 4px; } .msg-card .mc-actions { display: flex; gap: 6px; margin-top: 9px; } .msg-card .mc-actions button { flex: 1; font-size: 11px; padding: 6px 0; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink-2); } .msg-card .mc-actions button:hover { border-color: rgba(255,106,0,.4); color: #c85100; background: #fff7f1; } .quick-replies { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .quick-reply { font-size: 11.5px; padding: 6px 11px; border-radius: 999px; border: 1px solid rgba(255,106,0,.25); background: #fff8f3; color: #c85100; transition: .16s; } .quick-reply:hover { background: #ffefe4; transform: translateY(-1px); } .dl-btn { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; padding: 9px 14px; border-radius: 10px; background: linear-gradient(135deg, #ff7d1f, #ff5f00); color: #fff; font-size: 12.5px; font-weight: 600; text-decoration: none; box-shadow: 0 8px 18px rgba(255,95,0,.22); transition: .16s; } .dl-btn:hover { transform: translateY(-1px); } .typing .bubble { display: flex; gap: 4px; padding: 14px 16px; } .typing .bubble i { width: 6px; height: 6px; border-radius: 50%; background: #b7bfd0; display: block; animation: blink 1.2s infinite ease-in-out; } .typing .bubble i:nth-child(2) { animation-delay: .18s; } .typing .bubble i:nth-child(3) { animation-delay: .36s; } @keyframes blink { 0%, 80%, 100% { opacity: .3; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } } .tray { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 18px 8px; padding: 9px 14px; background: #fff7f1; border: 1px dashed rgba(255,106,0,.4); border-radius: 12px; font-size: 12px; color: #b04a00; } .tray-actions { display: flex; gap: 8px; } .composer { border-top: 1px solid var(--line); padding: 12px 18px 16px; background: var(--surface); } .composer-top { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; } .select { font-size: 11.5px; color: var(--ink-2); border: 1px solid var(--line); background: var(--surface-2); border-radius: 9px; padding: 5px 8px; outline: none; } .composer-top .hint { font-size: 11px; color: var(--muted); } .composer-top .icon-btn { margin-left: auto; } .composer-row { display: flex; align-items: center; gap: 9px; } .mic { width: 44px; height: 44px; flex: none; border-radius: 13px; border: 1px solid var(--line); background: var(--surface-2); font-size: 18px; transition: .18s; } .mic:hover { border-color: rgba(255,106,0,.4); background: #fff7f1; } .mic.rec { background: #ffefef; border-color: #ff6b6b; animation: pulse 1.4s infinite; } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,107,107,.45); } 70% { box-shadow: 0 0 0 12px rgba(255,107,107,0); } 100% { box-shadow: 0 0 0 0 rgba(255,107,107,0); } } #input { flex: 1; min-width: 0; height: 44px; border-radius: 13px; border: 1px solid var(--line); background: var(--surface-2); padding: 0 14px; font-size: 13.5px; color: var(--ink); outline: none; transition: .18s; } #input:focus { border-color: rgba(255,106,0,.5); background: #fff; box-shadow: 0 0 0 4px rgba(255,106,0,.08); } .send { height: 44px; padding: 0 22px; border-radius: 13px; } /* ---------------- 右栏 ---------------- */ .panel { display: flex; flex-direction: column; min-height: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 22px; box-shadow: var(--shadow); overflow: hidden; } .panel-tabs { display: flex; align-items: center; gap: 6px; padding: 14px 16px 10px; border-bottom: 1px solid var(--line); } .tab { font-size: 13px; border: none; background: transparent; color: var(--muted); padding: 7px 12px; border-radius: 9px; font-weight: 500; } .tab.active { background: var(--accent-soft); color: #c85100; font-weight: 600; } .feed-note { margin-left: auto; font-size: 10.5px; color: var(--muted); max-width: 55%; text-align: right; line-height: 1.35; } .panel-body { flex: 1; overflow-y: auto; padding: 14px 16px 18px; display: flex; flex-direction: column; gap: 12px; } .empty { font-size: 12.5px; color: var(--muted); line-height: 1.7; text-align: center; padding: 40px 10px; } /* 商品卡 */ .pcard { display: flex; gap: 12px; border: 1px solid var(--line); border-radius: 15px; padding: 12px; background: var(--surface); transition: .18s; } .pcard:hover { border-color: rgba(255,106,0,.3); box-shadow: 0 10px 24px rgba(18,26,44,.07); transform: translateY(-1px); } .pcard .media { flex: none; display: grid; place-items: center; width: 84px; } .pimg { width: 80px; height: 80px; object-fit: contain; border-radius: 12px; background: #f7f8fb; border: 1px solid var(--line); } .mc-img { object-fit: contain; border-radius: 9px; background: #fff; border: 1px solid var(--line); } .phone { width: 50px; height: 96px; border-radius: 12px; background: linear-gradient(160deg, var(--c1), var(--c2)); position: relative; box-shadow: inset 0 0 0 1px rgba(255,255,255,.35), 0 8px 16px rgba(18,26,44,.16); } .phone::before { content: ""; position: absolute; inset: 3px; border-radius: 10px; background: linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,.02) 40%, rgba(0,0,0,.28)); } .phone::after { content: ""; position: absolute; left: 50%; top: 6px; transform: translateX(-50%); width: 17px; height: 4px; border-radius: 3px; background: rgba(0,0,0,.5); } .phone .lens { position: absolute; top: 12px; left: 7px; width: 11px; height: 11px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #2a3242, #05070c); box-shadow: 0 0 0 1px rgba(255,255,255,.25); } .pcard .info { flex: 1; min-width: 0; } .pcard .row1 { display: flex; align-items: center; gap: 6px; } .brand-tag { font-size: 10px; font-weight: 600; color: #fff; border-radius: 6px; padding: 2px 6px; background: linear-gradient(135deg, #3a4356, #232a38); } .match { margin-left: auto; font-size: 10px; color: var(--accent-2); background: var(--accent-2-soft); border-radius: 999px; padding: 2px 7px; } .pname { font-size: 13.5px; font-weight: 600; margin: 5px 0 3px; } .price-line { display: flex; align-items: baseline; gap: 6px; } .price { font-size: 17px; font-weight: 700; color: #e85d00; letter-spacing: -.3px; } .price small { font-size: 10.5px; font-weight: 500; color: var(--muted); letter-spacing: 0; margin-left: 2px; } .web-tag { font-size: 10px; color: #1d63c9; background: #eef4ff; border-radius: 5px; padding: 1px 6px; font-weight: 600; } .cat-tag { font-size: 10px; color: #5b6472; background: #eef1f6; border-radius: 5px; padding: 1px 6px; } .meta { font-size: 11px; color: var(--muted); margin-top: 4px; display: flex; gap: 10px; flex-wrap: wrap; } .meta .rate { color: #d97600; } .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; } .tags span { font-size: 10px; color: #5a6479; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; } .reasons { margin-top: 7px; display: flex; flex-direction: column; gap: 3px; } .reasons span { font-size: 10.5px; color: #0a7a58; } .actions { display: flex; gap: 7px; margin-top: 10px; } .actions button { flex: 1; font-size: 11.5px; padding: 7px 0; border-radius: 9px; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink-2); transition: .16s; } .actions button:hover { border-color: rgba(255,106,0,.4); color: #c85100; background: #fff7f1; } .actions button.on { background: var(--accent); border-color: var(--accent); color: #fff; } /* 对比表 */ .cmp-head { display: flex; flex-direction: column; gap: 8px; } .cmp-title { font-size: 12.5px; font-weight: 600; margin-bottom: 4px; } .cmp-scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 13px; } .cmp-table { border-collapse: collapse; width: 100%; min-width: 340px; font-size: 11.5px; } .cmp-table th, .cmp-table td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: center; vertical-align: middle; } .cmp-table thead th { background: var(--surface-2); font-weight: 600; color: var(--ink); border-bottom: 1px solid var(--line); } .cmp-table thead th .th-name { font-size: 12px; } .cmp-table thead th .th-brand { font-size: 10px; color: var(--muted); font-weight: 400; } .cmp-table tbody th { text-align: left; color: var(--muted); font-weight: 500; white-space: nowrap; background: var(--surface-2); } .cmp-table td.best { background: rgba(255,106,0,.07); color: #c85100; font-weight: 700; position: relative; } .cmp-table td.best::after { content: "最优"; position: absolute; top: 3px; right: 4px; font-size: 8px; color: #fff; background: var(--accent); border-radius: 4px; padding: 1px 3px; } .cmp-table td .small-note { display: block; font-size: 9.5px; color: var(--muted); font-weight: 400; } .cmp-table tr.core th { color: var(--ink-2); font-weight: 600; } .cmp-section-label { font-size: 10.5px; color: var(--muted); margin: 10px 2px 6px; letter-spacing: .5px; } .advice { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } .advice-item { background: var(--surface-2); border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 10px; padding: 9px 11px; } .advice-item b { font-size: 11.5px; color: #c85100; display: block; margin-bottom: 2px; } .advice-item p { margin: 0; font-size: 11.5px; color: var(--ink-2); line-height: 1.55; } /* ---------------- 可解释购买方案 ---------------- */ .plan-card { margin-top: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; padding: 14px; box-shadow: 0 8px 20px rgba(18, 26, 44, .06); max-width: 680px; } .plan-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .plan-badge { flex: none; font-size: 10px; font-weight: 700; color: #c85100; background: var(--accent-soft); border: 1px solid rgba(255, 106, 0, .25); border-radius: 6px; padding: 2px 7px; letter-spacing: .5px; } .plan-title { font-size: 13.5px; font-weight: 700; color: var(--ink); } .plan-facts { display: flex; flex-direction: column; gap: 6px; margin-bottom: 11px; } .plan-fact { display: flex; gap: 8px; font-size: 11.5px; line-height: 1.55; } .plan-fact b { flex: none; width: 34px; color: var(--muted); font-weight: 600; } .plan-fact span { color: var(--ink-2); } .plan-primary { display: flex; align-items: center; gap: 11px; padding: 10px; background: linear-gradient(135deg, #fff8f3, #fff); border: 1px solid rgba(255, 106, 0, .18); border-radius: 12px; } .plan-primary img { width: 52px; height: 52px; object-fit: contain; flex: none; border-radius: 10px; background: #f7f8fb; border: 1px solid var(--line); } .plan-primary-info { min-width: 0; } .plan-pname { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: 13px; font-weight: 700; color: var(--ink); } .plan-price { font-size: 15px; font-weight: 800; color: #e85d00; } .plan-sub { margin-top: 3px; font-size: 10.5px; color: var(--muted); } .plan-block { margin-top: 11px; } .plan-block h5 { display: flex; align-items: center; gap: 7px; margin: 0 0 6px; font-size: 11px; font-weight: 700; color: var(--ink); letter-spacing: .3px; } .plan-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; } .plan-list li { position: relative; padding-left: 13px; font-size: 11.5px; line-height: 1.55; color: var(--ink-2); } .plan-list li::before { content: ""; position: absolute; left: 2px; top: 6px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); } .plan-list.risk li::before { background: #e0a200; } .plan-review { margin: 0 0 6px; font-size: 11.5px; line-height: 1.55; color: var(--ink-2); } .risk-tag { font-size: 9.5px; font-weight: 600; padding: 2px 7px; border-radius: 999px; } .risk-tag.low { color: #0b7a58; background: rgba(15, 166, 120, .12); } .risk-tag.mid { color: #a86b00; background: rgba(224, 162, 0, .14); } .plan-alts { display: flex; flex-direction: column; gap: 7px; } .plan-alt { display: flex; align-items: center; gap: 9px; padding: 8px 9px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; } .plan-alt img { width: 36px; height: 36px; object-fit: contain; flex: none; border-radius: 8px; background: #fff; border: 1px solid var(--line); } .plan-alt b { display: block; font-size: 11.5px; color: var(--ink); } .plan-alt span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--muted); } .plan-empty { font-size: 11px; color: var(--muted); } .plan-verdict { margin-top: 11px; padding: 10px 11px; border-radius: 8px; border-left: 3px solid var(--accent); background: var(--surface-2); font-size: 11.5px; line-height: 1.6; color: var(--ink-2); } .plan-verdict b { color: #c85100; margin-right: 4px; } .plan-steps { margin: 0; padding-left: 17px; font-size: 11.5px; line-height: 1.6; color: var(--ink-2); } .plan-steps li { margin-bottom: 3px; } .plan-card.compact { margin-top: 0; } .plan-card.compact .plan-primary img { width: 44px; height: 44px; } .feed-label { margin: 10px 2px 2px; font-size: 10.5px; letter-spacing: .5px; color: var(--muted); } /* ---------------- 弹窗 / Toast ---------------- */ .modal { position: fixed; inset: 0; z-index: 50; background: rgba(16, 22, 34, .42); backdrop-filter: blur(6px); display: grid; place-items: center; padding: 24px; } .modal-card { width: min(560px, 100%); max-height: 82vh; background: var(--surface); border-radius: 20px; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; overflow: hidden; animation: pop .22s ease-out; } @keyframes pop { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } } .modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--line); } .modal-head h2 { font-size: 16px; margin: 0; } .modal-body { padding: 16px 20px; overflow-y: auto; font-size: 12.5px; color: var(--ink-2); line-height: 1.7; } .modal-foot { display: flex; justify-content: space-between; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--line); } /* ---------------- 商品详情 · 2D / 3D 展示 ---------------- */ .product-modal-card { width: min(660px, 100%); max-height: 88vh; } .pm-head { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .pm-brand { font-size: 10.5px; color: var(--muted); letter-spacing: .3px; } .pm-head h2 { font-size: 16px; margin: 0; } .pm-switch { display: flex; gap: 8px; padding: 12px 20px 0; } .pm-tab { font-size: 12px; padding: 7px 14px; border-radius: 10px; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink-2); transition: .16s; } .pm-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 6px 14px rgba(255, 95, 0, .22); } .pm-body { padding-top: 12px; } /* 2D 平面图 */ .pm-stage { position: relative; height: 260px; border-radius: 16px; border: 1px solid var(--line); background: radial-gradient(circle at 50% 38%, #fff 0%, #f4f6fb 62%, #e9edf6 100%); display: grid; place-items: center; overflow: hidden; } .pm-stage img { max-width: 74%; max-height: 74%; object-fit: contain; transition: transform .3s ease; will-change: transform; } .pm-stage:hover img { transform: scale(1.5); } .pm-tip { margin-top: 7px; font-size: 10.5px; color: var(--muted); text-align: center; } /* 3D 交互视图 */ .viewer3d { position: relative; height: 280px; border-radius: 16px; border: 1px solid var(--line); background: radial-gradient(circle at 50% 30%, #ffffff 0%, #eef2f9 55%, #dde3ee 100%); perspective: 1000px; overflow: hidden; cursor: grab; touch-action: none; } .viewer3d:active { cursor: grabbing; } .stage3d { position: absolute; inset: 0; display: grid; place-items: center; transform-style: preserve-3d; will-change: transform; } .card3d { position: relative; width: 190px; height: 190px; transform-style: preserve-3d; } .face3d { position: absolute; inset: 0; display: grid; place-items: center; border-radius: 16px; border: 1px solid #e3e8f2; background: #fff; backface-visibility: hidden; box-shadow: 0 22px 40px rgba(18, 26, 44, .18); } .face3d.front { transform: translateZ(12px); } .face3d.back { transform: rotateY(180deg) translateZ(12px); } .face3d img { max-width: 84%; max-height: 84%; object-fit: contain; } .face3d.back img { transform: scaleX(-1); } .ground3d { position: absolute; left: 50%; bottom: 26px; width: 210px; height: 36px; transform: translateX(-50%); pointer-events: none; filter: blur(3px); background: radial-gradient(ellipse at center, rgba(24, 32, 52, .3), rgba(24, 32, 52, 0) 70%); } .viewer3d-hint { position: absolute; left: 12px; bottom: 10px; font-size: 10px; color: #8a92a4; background: rgba(255, 255, 255, .8); border: 1px solid rgba(224, 229, 240, .9); border-radius: 999px; padding: 3px 9px; pointer-events: none; } .viewer3d-reset { position: absolute; right: 10px; top: 10px; z-index: 2; font-size: 10.5px; padding: 4px 10px; border-radius: 999px; border: 1px solid #dfe4ef; background: rgba(255, 255, 255, .9); color: #5b6478; } .viewer3d-reset:hover { border-color: var(--accent); color: #c85100; } /* 真实 GLB 模型 */ .model3d { position: relative; height: 300px; border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: radial-gradient(circle at 50% 30%, #ffffff 0%, #eef2f9 55%, #dde3ee 100%); } .model3d model-viewer { width: 100%; height: 100%; background: transparent; } /* 详情信息 */ .pm-info { padding-top: 14px; } .pm-loading { padding: 18px; text-align: center; color: var(--muted); font-size: 12px; } .pm-price-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } .pm-big-price { font-size: 24px; font-weight: 800; color: #e85d00; letter-spacing: -.5px; } .pm-big-price small { font-size: 11px; font-weight: 500; color: var(--muted); } .pm-stat { font-size: 11.5px; color: var(--muted); } .pm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } .pm-tags span { font-size: 10.5px; color: #5a6479; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; } .pm-block { margin-top: 14px; } .pm-block h5 { margin: 0 0 6px; font-size: 11.5px; color: var(--ink); font-weight: 700; } .pm-points { margin: 0; padding-left: 18px; font-size: 11.5px; color: var(--ink-2); line-height: 1.65; } .pm-points li { margin-bottom: 2px; } .pm-spec-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 12px; } .pm-spec { display: flex; gap: 8px; font-size: 11.5px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; } .pm-spec b { flex: none; color: var(--muted); font-weight: 500; } .pm-spec span { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; } .pm-audience { margin: 0; font-size: 11.5px; color: var(--ink-2); } .pm-note { margin-top: 12px; font-size: 11px; color: #8a6d00; background: #fff9e6; border: 1px solid #f5e3a8; border-radius: 9px; padding: 8px 10px; } #pmCompare.on { background: var(--accent); border-color: var(--accent); color: #fff; } @media (max-width: 560px) { .pm-stage { height: 200px; } .viewer3d { height: 220px; } .card3d { width: 150px; height: 150px; } .pm-spec-grid { grid-template-columns: 1fr; } } .pref-block { margin-bottom: 14px; } .pref-block h4 { font-size: 12px; margin: 0 0 7px; color: var(--ink); } .bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; font-size: 11.5px; } .bar-row .k { width: 64px; color: var(--ink-2); flex: none; } .bar-track { flex: 1; height: 6px; background: var(--surface-2); border-radius: 99px; overflow: hidden; } .bar-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #ffb26b, #ff5f00); } .bar-row .v { width: 22px; text-align: right; color: var(--muted); font-size: 10.5px; } .log-list { display: flex; flex-direction: column; gap: 6px; } .log-item { font-size: 11.5px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px; background: var(--surface-2); } .log-item b { color: var(--ink); } .log-item span { color: var(--muted); font-size: 10.5px; } .toast { position: fixed; left: 50%; bottom: 30px; transform: translate(-50%, 14px); background: #161c29; color: #fff; font-size: 12.5px; padding: 11px 18px; border-radius: 12px; opacity: 0; pointer-events: none; transition: .25s; box-shadow: 0 14px 30px rgba(0,0,0,.25); z-index: 80; max-width: 80vw; line-height: 1.5; } .toast.show { opacity: 1; transform: translate(-50%, 0); } /* 滚动条 */ .messages::-webkit-scrollbar, .panel-body::-webkit-scrollbar, .side::-webkit-scrollbar, .msg-cards::-webkit-scrollbar { width: 6px; height: 6px; } .messages::-webkit-scrollbar-thumb, .panel-body::-webkit-scrollbar-thumb, .side::-webkit-scrollbar-thumb, .msg-cards::-webkit-scrollbar-thumb { background: #d9dee8; border-radius: 99px; } /* ---------------- 响应式 ---------------- */ @media (max-width: 1280px) { .layout { grid-template-columns: 240px minmax(0, 1fr) 340px; gap: 12px; padding: 4px 16px 16px; } } @media (max-width: 1080px) { .layout { grid-template-columns: minmax(0, 1fr) 330px; } .side { display: none; } .side.open { display: flex; position: fixed; z-index: 45; top: 64px; left: 12px; width: min(320px, 88vw); max-height: calc(100vh - 84px); padding: 12px; background: var(--bg); border-radius: 18px; box-shadow: var(--shadow-lg); overflow-y: auto; } } @media (max-width: 820px) { .layout { grid-template-columns: minmax(0, 1fr); } .panel { display: none; } .top-actions .pill { display: none; } .only-mobile { display: inline-block; } .panel.open { display: flex; position: fixed; inset: 56px 12px 12px; z-index: 40; box-shadow: var(--shadow-lg); } .messages { padding: 14px; } } .only-mobile { display: none; } .only-compact { display: none; } @media (max-width: 1080px) { .only-compact { display: inline-block; } } ``` --- ## web/js/app.js ```javascript import { VoiceInput, speak } from "./voice.js"; const $ = (sel) => document.querySelector(sel); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); // ------------------------------------------------------------ Markdown(仅用于导购回复) function mdInline(text) { let s = esc(text); s = s.replace(/\*\*(.+?)\*\*/g, "$1"); s = s.replace(/`([^`]+)`/g, "$1"); // Markdown 链接(下载地址是站内相对路径) s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); s = s.replace(/(https?:\/\/[^\s<]+)/g, (u) => `${u}`); return s; } function splitRow(line) { return String(line) .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((c) => c.trim()); } function renderMarkdown(text) { const lines = String(text || "").split(/\r?\n/); const html = []; let i = 0; while (i < lines.length) { const line = lines[i]; // 表格:表头 + |---| 分隔行 + 数据行 if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|\-]+\|\s*$/.test(lines[i + 1])) { const head = splitRow(line); i += 2; const rows = []; while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(splitRow(lines[i])); i += 1; } html.push( `
    ${head .map((h) => ``) .join("")}${rows .map((r) => `${r.map((c) => ``).join("")}`) .join("")}
    ${mdInline(h)}
    ${mdInline(c)}
    ` ); continue; } if (/^\s*>\s?/.test(line)) { const buf = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i += 1; } html.push(`
    ${buf.map(mdInline).join("
    ")}
    `); continue; } if (/^\s*(?:[-*]|\d+\.)\s+/.test(line)) { const ordered = /^\s*\d+\./.test(line); const buf = []; while (i < lines.length && /^\s*(?:[-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i].replace(/^\s*(?:[-*]|\d+\.)\s+/, "")); i += 1; } const tag = ordered ? "ol" : "ul"; html.push(`<${tag} class="md-list">${buf.map((b) => `
  • ${mdInline(b)}
  • `).join("")}`); continue; } if (!line.trim()) { i += 1; continue; } html.push(`

    ${mdInline(line)}

    `); i += 1; } return html.join(""); } function uid() { const saved = localStorage.getItem("sg_uid"); if (saved) return saved; const id = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; localStorage.setItem("sg_uid", id); return id; } const state = { userId: uid(), config: null, profile: null, feed: [], compare: null, selection: [], dialect: { lang: "zh-CN", code: "" }, tts: false, busy: false, lastParsed: null, productMap: {}, modalProduct: null, }; async function api(path, opts = {}) { const init = { headers: { "Content-Type": "application/json" }, cache: "no-store", ...opts }; if (opts.body) init.body = JSON.stringify(opts.body); const res = await fetch(path, init); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `请求失败(${res.status})`); return data; } let toastTimer = null; function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove("show"), 3200); } // ------------------------------------------------------------ 商品视觉 function phoneEl(p) { const d = document.createElement("div"); d.className = "phone"; d.style.setProperty("--c1", p.color || "#ff6900"); d.style.setProperty("--c2", p.color2 || "#ffb26b"); const lens = document.createElement("span"); lens.className = "lens"; d.appendChild(lens); return d; } function thumbEl(p, cls) { if (p.image) { const img = document.createElement("img"); img.className = cls; img.src = p.image; img.alt = p.name || ""; img.loading = "lazy"; return img; } return phoneEl(p); } // ------------------------------------------------------------ 商品卡 function productCardEl(p) { state.productMap[p.id] = p; const el = document.createElement("div"); el.className = "pcard"; el.dataset.id = p.id; const media = document.createElement("div"); media.className = "media"; media.appendChild(thumbEl(p, "pimg")); const info = document.createElement("div"); info.className = "info"; info.innerHTML = `
    ${esc(p.brand)} ${p.category ? `${esc(p.category)}` : ""} ${p.web ? `联网` : ""} ${p.match ? `匹配 ${p.match}%` : ""}
    ${esc(p.name)}
    ¥${p.price}${p.web ? ` 参考价` : ""}
    好评 ${esc(p.ratingText)} 销量 ${esc(p.salesText)}
    ${p.tags.slice(0, 4).map((t) => `${esc(t)}`).join("")}
    ${p.reasons?.length ? `
    ${p.reasons.map((r) => `· ${esc(r)}`).join("")}
    ` : ""} `; const actions = document.createElement("div"); actions.className = "actions"; const btnOpen = document.createElement("button"); btnOpen.textContent = "详情"; btnOpen.dataset.act = "open"; const btnCompare = document.createElement("button"); btnCompare.textContent = "加入对比"; btnCompare.dataset.act = "compare"; const btnAsk = document.createElement("button"); btnAsk.textContent = "问这款"; btnAsk.dataset.act = "ask"; actions.append(btnOpen, btnCompare, btnAsk); info.appendChild(actions); el.append(media, info); syncCardButton(el); return el; } function syncCardButton(el) { const id = el.dataset.id; const btn = el.querySelector('[data-act="compare"]'); if (!btn) return; const on = state.selection.includes(id); btn.classList.toggle("on", on); btn.textContent = on ? "已加入对比" : "加入对比"; } function syncAllCardButtons() { document.querySelectorAll(".pcard").forEach(syncCardButton); syncModalCompare(); updateTray(); } function updateTray() { const tray = $("#tray"); const count = state.selection.length; tray.classList.toggle("hidden", count === 0); $("#trayText").textContent = `已选 ${count}/3 款:${count ? state.selection.map((id) => nameOf(id)).join("、") : ""}`; $("#trayCompare").disabled = count < 2; } function nameOf(id) { const found = state.productMap[id]; return found ? found.name.replace(/\s+/g, "") : id; } async function toggleSelection(id) { if (state.selection.includes(id)) { state.selection = state.selection.filter((x) => x !== id); } else { if (state.selection.length >= 3) { toast("最多同时对比 3 款,先移除一款吧"); return; } state.selection.push(id); } syncAllCardButtons(); if (state.selection.length >= 2) { await generateCompare(); } } async function generateCompare(ids = null) { const target = ids || state.selection; if (target.length < 2) return; try { const res = await api("/api/compare", { method: "POST", body: { userId: state.userId, ids: target, scenes: state.lastParsed?.scenes || [] }, }); state.compare = res.compare; renderCompare(res.compare); switchTab("compare"); if (!ids) toast("对比表已生成,见右侧"); } catch (err) { toast("生成对比表失败:" + err.message); } } // ------------------------------------------------------------ 对比表渲染 function renderCompare(cmp) { const pane = $("#comparePane"); pane.innerHTML = ""; if (!cmp) { pane.innerHTML = '
    选择 2-3 款商品后,这里会生成「价格 / 好评 / 销量」对比表
    '; return; } const cols = cmp.products; const cell = (row, idx) => { const isBest = cmp.best[row.key] === idx; return `${esc(row.values[idx])}`; }; const coreRows = cmp.rows.filter((r) => r.group === "core"); const specRows = cmp.rows.filter((r) => r.group === "spec"); let html = `
    价格 · 好评率 · 销量 对比
    ${cols .map((p) => ``) .join("")}`; coreRows.forEach((row) => { html += `${cols.map((_, i) => cell(row, i)).join("")}`; }); html += `
    ${esc(p.name)}
    ${esc(p.brand)}
    ${esc(row.label)}
    `; specRows.forEach((row) => { html += `${cols.map((_, i) => ``).join("")}`; }); html += `
    ${esc(row.label)}${esc(row.values[i])}
    `; const advice = cmp.advice .map((a) => `
    ${esc(a.title)}

    ${esc(a.text)}

    `) .join(""); html += `
    ${advice}
    `; pane.innerHTML = html; } // ------------------------------------------------------------ 推荐流 function renderFeed(data) { const pane = $("#feedPane"); pane.innerHTML = ""; if (!data.products?.length) { pane.innerHTML = '
    暂无推荐,先在左侧对话里描述一下需求吧
    '; return; } const plan = planCardEl(data.plan, true); if (plan) pane.appendChild(plan); const label = document.createElement("div"); label.className = "feed-label"; label.textContent = `候选清单 · ${data.products.length} 款`; pane.appendChild(label); data.products.forEach((p) => pane.appendChild(productCardEl(p))); $("#feedNote").textContent = data.note || "按你的实时需求排序"; if (data.profile) renderProfile(data.profile); } async function refreshFeed() { try { const data = await api(`/api/recommend?userId=${state.userId}&limit=6`); state.feed = data.products; renderFeed(data); } catch (err) { console.warn(err); } } // ------------------------------------------------------------ 画像 function renderProfile(profile) { if (!profile) return; state.profile = profile; $("#profileSummary").textContent = profile.summary || "还没有形成明显偏好"; $("#statSearch").textContent = profile.searchCount ?? 0; $("#statView").textContent = profile.viewedCount ?? 0; $("#statUntouched").textContent = profile.untouched ?? 0; const chips = []; (profile.scenes || []).forEach((s) => chips.push({ text: s, cls: "hot" })); (profile.brands || []).forEach((b) => chips.push({ text: b, cls: "blue" })); if (profile.budget) chips.push({ text: `预算 ${profile.budget.min}-${profile.budget.max}`, cls: "" }); (profile.tags || []).forEach((t) => chips.push({ text: t, cls: "" })); $("#profileChips").innerHTML = chips.length ? chips.map((c) => `${esc(c.text)}`).join("") : '多聊几句,我就能记住你'; const recent = $("#recentSearches"); recent.innerHTML = profile.recentSearches?.length ? profile.recentSearches.map((s) => `${esc(s)}`).join("") : '还没有搜索记录'; $("#avoidToggle").checked = profile.avoidNew !== false; } $("#avoidToggle").addEventListener("change", async (e) => { try { const res = await api("/api/settings", { method: "POST", body: { userId: state.userId, avoidNew: e.target.checked }, }); renderProfile(res.profile); refreshFeed(); toast(e.target.checked ? "已开启:优先推荐看过的商品" : "已关闭:允许推荐从未看过的商品"); } catch (err) { toast("设置失败:" + err.message); } }); // ------------------------------------------------------------ 消息 function scrollBottom() { const box = $("#messages"); box.scrollTop = box.scrollHeight; } function addUserMessage(text) { const msg = document.createElement("div"); msg.className = "msg user"; const body = document.createElement("div"); body.className = "body"; const bubble = document.createElement("div"); bubble.className = "bubble"; bubble.textContent = text; body.appendChild(bubble); msg.appendChild(body); $("#messages").appendChild(msg); scrollBottom(); } function addBotMessage(res) { const msg = document.createElement("div"); msg.className = "msg bot"; const avatar = document.createElement("div"); avatar.className = "avatar"; avatar.textContent = "智"; const body = document.createElement("div"); body.className = "body"; const bubble = document.createElement("div"); bubble.className = "bubble"; if (res.markdown) { bubble.classList.add("md"); body.classList.add("wide"); bubble.innerHTML = renderMarkdown(res.reply || ""); } else { bubble.textContent = res.reply || ""; } body.appendChild(bubble); if (res.download?.url) { const link = document.createElement("a"); link.className = "dl-btn"; link.href = res.download.url; link.download = res.download.name || ""; link.rel = "noopener"; link.textContent = `📥 下载${res.download.kind || "表格"}(${res.download.name || "文件"})`; body.appendChild(link); } if (res.dialect) { const tag = document.createElement("span"); tag.className = "chip blue"; tag.style.marginTop = "7px"; tag.style.display = "inline-block"; tag.textContent = `识别为 ${res.dialect.name}`; body.appendChild(tag); } const plan = planCardEl(res.plan, false); if (plan) body.appendChild(plan); if (res.compare) { const btn = document.createElement("button"); btn.className = "quick-reply"; btn.style.marginTop = "10px"; btn.textContent = "📊 打开对比表"; btn.addEventListener("click", () => switchTab("compare")); body.appendChild(btn); } if (res.products?.length) { const strip = document.createElement("div"); strip.className = "msg-cards"; res.products.forEach((p) => strip.appendChild(miniCardEl(p))); body.appendChild(strip); } if (res.quickReplies?.length) { const quick = document.createElement("div"); quick.className = "quick-replies"; res.quickReplies.forEach((q) => { const chip = document.createElement("button"); chip.className = "quick-reply"; chip.textContent = q; chip.addEventListener("click", () => handleQuick(q)); quick.appendChild(chip); }); body.appendChild(quick); } msg.append(avatar, body); $("#messages").appendChild(msg); scrollBottom(); } // ------------------------------------------------------------ 购买方案卡 function planCardEl(plan, compact = false) { if (!plan) return null; const el = document.createElement("div"); el.className = "plan-card" + (compact ? " compact" : ""); const riskItems = plan.risk?.items?.length ? plan.risk.items.map((r) => `
  • ${esc(r)}
  • `).join("") : "
  • 按平台常规保修政策执行
  • "; const alts = plan.alternatives?.length ? plan.alternatives .map( (a) => `
    ${a.image ? `` : ""}
    ${esc(a.name)}¥${a.price} · ${esc(a.role)}
    ` ) .join("") : '
    暂时没有更合适的备选
    '; el.innerHTML = `
    购买方案 ${esc(plan.title)}
    预算${esc(plan.budget.text)}
    场景${esc(plan.scenario.text)}
    ${plan.primary.image ? `` : ""}
    ${esc(plan.primary.name)}¥${plan.primary.price}
    好评 ${esc(plan.primary.ratingText)} · 销量 ${esc(plan.primary.salesText)}
    为什么适合你
      ${plan.primary.why.map((r) => `
    • ${esc(r)}
    • `).join("")}
    评价与售后风险${esc(plan.risk.level)}风险

    ${esc(plan.reviews.text)}

      ${riskItems}
    ${ compact ? "" : `
    备选方案
    ${alts}
    ` }
    结论${esc(plan.verdict)}
    ${ compact ? "" : `
    下单前三步
      ${plan.steps .map((s) => `
    1. ${esc(s)}
    2. `) .join("")}
    ` } `; return el; } function miniCardEl(p) { state.productMap[p.id] = p; const el = document.createElement("div"); el.className = "msg-card"; const media = thumbEl(p, "mc-img"); if (media.classList.contains("mc-img")) { media.style.width = "46px"; media.style.height = "46px"; } else { media.style.width = "34px"; media.style.height = "66px"; media.style.borderRadius = "9px"; } const top = document.createElement("div"); top.className = "mc-top"; top.append(media); const t = document.createElement("div"); t.innerHTML = `
    ${esc(p.name)}
    ¥${p.price}
    好评 ${esc(p.ratingText)} · ${esc(p.salesText)}${p.web ? " · 联网参考价" : ""}
    `; top.appendChild(t); el.appendChild(top); const acts = document.createElement("div"); acts.className = "mc-actions"; const b1 = document.createElement("button"); b1.textContent = "对比"; b1.addEventListener("click", () => { toggleSelection(p.id); }); const b2 = document.createElement("button"); b2.textContent = "详情"; b2.addEventListener("click", () => openProduct(p.id)); acts.append(b1, b2); el.appendChild(acts); return el; } let typingEl = null; function addTyping() { const msg = document.createElement("div"); msg.className = "msg bot typing"; const avatar = document.createElement("div"); avatar.className = "avatar"; avatar.textContent = "智"; const body = document.createElement("div"); body.className = "body"; const bubble = document.createElement("div"); bubble.className = "bubble"; bubble.innerHTML = ""; body.appendChild(bubble); msg.append(avatar, body); $("#messages").appendChild(msg); typingEl = msg; scrollBottom(); return msg; } function handleQuick(q) { const text = q.replace(/^对比这 \d+ 款$/, "__COMPARE_SEL__"); if (text === "__COMPARE_SEL__") { generateCompare(); return; } if (q.includes("清空我的记忆")) { resetMemory(); return; } if (q === "直接买哪款更稳") { send("帮我推荐一款口碑最好的"); return; } send(q); } // ------------------------------------------------------------ 发送 async function send(text) { const message = (text || "").trim(); if (!message || state.busy) return; if (/清空.*记忆|清除.*记忆/.test(message)) { resetMemory(); return; } addUserMessage(message); $("#input").value = ""; state.busy = true; $("#sendBtn").disabled = true; addTyping(); try { const res = await api("/api/chat", { method: "POST", body: { userId: state.userId, message, dialect: state.dialect.code || undefined }, }); typingEl?.remove(); typingEl = null; addBotMessage(res); state.lastParsed = res.parsed; renderProfile(res.profile); if (res.compare) { state.compare = res.compare; renderCompare(res.compare); switchTab("compare"); } if (res.products?.length) { res.products.forEach((p) => (state.productMap[p.id] = p)); } refreshFeed(); if (state.tts) speak(res.markdown ? String(res.reply || "").replace(/[#>*`|]/g, " ").replace(/\s+/g, " ") : res.reply); } catch (err) { typingEl?.remove(); typingEl = null; addBotMessage({ reply: "抱歉,服务出了点问题:" + err.message, quickReplies: ["再试一次", "换个说法"] }); } finally { state.busy = false; $("#sendBtn").disabled = false; $("#input").focus(); } } // ------------------------------------------------------------ 语音 const voice = new VoiceInput({ onInterim: (t) => { $("#input").value = t; }, onFinal: (t) => { $("#input").value = t; send(t); }, onState: (s) => { const mic = $("#micBtn"); mic.classList.toggle("rec", s === "recording"); $("#voiceHint").textContent = s === "recording" ? "正在聆听…" : s === "unsupported" ? "当前浏览器不支持语音识别,可点击麦克风使用模拟输入" : "支持粤语 / 四川话 / 东北话等方言"; }, onError: (msg) => { toast(msg); $("#micBtn").classList.remove("rec"); }, }); $("#micBtn").addEventListener("click", () => { if (voice.active) { voice.stop(); return; } const started = voice.start(state.dialect.lang); if (!started) { const sample = voice.mockSample(state.dialect.code); toast(`已切换到模拟语音输入:${sample}`); $("#input").value = sample; setTimeout(() => send(sample), 400); } }); $("#dialectSelect").addEventListener("change", (e) => { const [lang, code] = e.target.value.split("|"); state.dialect = { lang, code: code || "" }; const label = e.target.options[e.target.selectedIndex].text; toast(`语音输入语言已切换为:${label}${code ? "(会自动识别方言词)" : ""}`); }); function buildDialectOptions() { const sel = $("#dialectSelect"); const langs = state.config?.speechLanguages || [{ lang: "zh-CN", label: "普通话" }]; sel.innerHTML = langs .map((l) => ``) .join(""); } $("#ttsBtn").addEventListener("click", () => { state.tts = !state.tts; $("#ttsBtn").classList.toggle("active", state.tts); toast(state.tts ? "已开启语音播报" : "已关闭语音播报"); if (!state.tts && "speechSynthesis" in window) window.speechSynthesis.cancel(); }); // ------------------------------------------------------------ 记忆库弹窗 async function openMemory() { $("#memoryModal").classList.remove("hidden"); $("#memoryBody").innerHTML = '
    读取中…
    '; try { const data = await api(`/api/memory?userId=${state.userId}`); const raw = data.raw || {}; const prefs = raw.preferences || {}; const blocks = []; const barBlock = (title, map, fixed) => { const items = fixed ? fixed.map(([label, key]) => [label, (map || {})[key] || 0]) : Object.entries(map || {}) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .filter(([, v]) => v > 0); if (!items.length) return `

    ${title}

    暂无记录
    `; const max = Math.max(1, ...items.map(([, v]) => v)); return `

    ${title}

    ${items .map( ([k, v]) => `
    ${esc(k)}
    ${v}
    ` ) .join("")}
    `; }; blocks.push(barBlock("品类偏好", prefs.category)); blocks.push( barBlock("场景偏好 · 室内 / 室外", prefs.environment, [ ["室内", "indoor"], ["室外", "outdoor"], ]) ); blocks.push( barBlock("品牌偏好 · 高奢 / 热门 / 经济 / 小众", prefs.brandTier, [ ["高奢", "luxury"], ["热门", "popular"], ["经济", "economy"], ["小众", "niche"], ]) ); blocks.push(barBlock("功能场景偏好(搜索/浏览累计)", prefs.scene)); blocks.push(barBlock("标签偏好", prefs.tag)); const searches = (raw.searches || []).slice(-6).reverse(); blocks.push( `

    历史搜索(最近 6 条)

    ${ searches.length ? searches .map( (s) => `
    ${esc(s.text)}${ s.dialect ? ` [${esc(s.dialect)}]` : "" }
    ${esc(s.ts)} · 意图:${esc(s.intent)}${s.budget ? ` · 预算 ${s.budget.min}-${s.budget.max}` : ""}
    ` ) .join("") : '
    暂无搜索记录
    ' }
    ` ); const persona = data.profile || {}; blocks.push( `

    当前画像

    ${esc(persona.summary)}
    搜索 ${ persona.searchCount } 次 · 浏览 ${persona.viewedCount} 款 · 未接触 ${persona.untouched} 款
    ` ); $("#memoryBody").innerHTML = blocks.join(""); } catch (err) { $("#memoryBody").innerHTML = `
    读取失败:${esc(err.message)}
    `; } } async function resetMemory() { try { await api(`/api/memory?userId=${state.userId}`, { method: "DELETE" }); state.selection = []; state.compare = null; renderCompare(null); $("#messages").innerHTML = ""; toast("记忆已清空,我们重新认识一下吧"); welcome(); refreshFeed(); } catch (err) { toast("清空失败:" + err.message); } } // ------------------------------------------------------------ 面板切换 function switchTab(name) { document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === name)); $("#feedPane").classList.toggle("hidden", name !== "feed"); $("#comparePane").classList.toggle("hidden", name !== "compare"); $("#feedNote").style.display = name === "feed" ? "" : "none"; if (window.innerWidth <= 820) { $("#panel").classList.add("open"); $("#panelToggle").textContent = "收起"; } } document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => switchTab(tab.dataset.tab)); }); $("#panelToggle").addEventListener("click", () => { const panel = $("#panel"); panel.classList.toggle("open"); $("#panelToggle").textContent = panel.classList.contains("open") ? "收起" : "推荐"; }); $("#sideToggle").addEventListener("click", () => { const side = document.querySelector(".side"); side.classList.toggle("open"); $("#sideToggle").textContent = side.classList.contains("open") ? "收起画像" : "我的画像"; }); $("#memoryBtn").addEventListener("click", openMemory); $("#memoryClose").addEventListener("click", () => $("#memoryModal").classList.add("hidden")); $("#memoryOk").addEventListener("click", () => $("#memoryModal").classList.add("hidden")); $("#memoryReset").addEventListener("click", async () => { $("#memoryModal").classList.add("hidden"); await resetMemory(); }); $("#memoryModal").addEventListener("click", (e) => { if (e.target.id === "memoryModal") $("#memoryModal").classList.add("hidden"); }); // ------------------------------------------------------------ 快捷需求 const QUICK_NEEDS = [ ["3000-4000 拍照好", "3000到4000元,拍照好一点的手机"], ["通勤降噪耳机", "室外通勤用,想要降噪好一点的耳机"], ["轻薄笔记本", "室内办公用,想要轻一点的笔记本"], ["小屏旗舰", "想要小屏旗舰,手感要好"], ["家里扫地机", "家里用,想买个扫地机器人"], ["看看我的画像", "你还记得我的喜好吗"], ]; function buildQuickGrid() { const grid = $("#quickGrid"); QUICK_NEEDS.forEach(([label, text]) => { const btn = document.createElement("button"); btn.className = "quick"; btn.textContent = label; btn.addEventListener("click", () => send(text)); grid.appendChild(btn); }); } // ------------------------------------------------------------ 事件绑定 $("#sendBtn").addEventListener("click", () => send($("#input").value)); $("#input").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) send($("#input").value); }); $("#trayCompare").addEventListener("click", () => generateCompare()); $("#trayClear").addEventListener("click", () => { state.selection = []; syncAllCardButtons(); }); // 商品卡按钮(事件委托) function onCardAction(e) { const btn = e.target.closest("button[data-act]"); if (btn) { const card = btn.closest(".pcard"); const id = card.dataset.id; if (btn.dataset.act === "compare") toggleSelection(id); else if (btn.dataset.act === "ask") send(`介绍一下${nameOf(id)}`); else if (btn.dataset.act === "open") openProduct(id); return; } const card = e.target.closest(".pcard"); if (card && e.target.closest(".media, .pname")) openProduct(card.dataset.id); } $("#feedPane").addEventListener("click", onCardAction); // ------------------------------------------------------------ 商品详情(2D / 3D 展示) async function openProduct(id) { const cached = state.productMap[id] || {}; $("#productModal").classList.remove("hidden"); $("#pmTitle").textContent = cached.name || "商品详情"; $("#pmBrand").textContent = [cached.brand, cached.category].filter(Boolean).join(" · "); $("#pmInfo").innerHTML = '
    正在读取商品资料…
    '; setProductView("2d"); try { const d = await api(`/api/product?id=${encodeURIComponent(id)}&userId=${encodeURIComponent(state.userId)}`); renderProductModal(d); } catch (err) { toast("详情加载失败:" + err.message); } } function renderProductModal(d) { state.modalProduct = d; $("#pmTitle").textContent = d.name; $("#pmBrand").textContent = [d.brand, d.category, d.web ? "联网参考价" : ""].filter(Boolean).join(" · "); const makeMedia = () => { if (!d.image) { const ph = phoneEl(d); ph.style.width = "150px"; ph.style.height = "150px"; return ph; } const img = document.createElement("img"); img.src = d.image; img.alt = d.name || ""; return img; }; // 2D 平面大图 const stage = $("#pmStage2d"); stage.innerHTML = ""; stage.appendChild(makeMedia()); // 3D 正反面 const front = $("#pmFaceFront"); const back = $("#pmFaceBack"); front.innerHTML = ""; back.innerHTML = ""; front.appendChild(makeMedia()); back.appendChild(makeMedia()); // 有真实 GLB 模型时优先用 ,否则回退到图片立体卡片 const hasModel = !!d.model3d; $("#viewer3d").classList.toggle("hidden", hasModel); const modelWrap = $("#pmModelViewer"); modelWrap.classList.toggle("hidden", !hasModel); // 延迟到「3D 展示」真正可见时再创建,避免在 display:none 容器里初始化导致不渲染 modelWrap.innerHTML = ""; $("#pm3dTip").textContent = hasModel ? "3D 模型 · 拖动旋转 / 滚轮缩放 / 双指缩放" : "3D 交互视图 · 按住拖动可 360° 旋转"; const specs = (d.specs || []) .map((s) => `
    ${esc(s.label)}${esc(s.value)}
    `) .join(""); const highlights = (d.highlights || []).map((h) => `
  • ${esc(h)}
  • `).join(""); $("#pmInfo").innerHTML = `
    ¥${d.price}${d.web ? " 参考价" : ""} 好评 ${esc(d.ratingText)} · 销量 ${esc(d.salesText)}
    ${(d.tags || []).map((t) => `${esc(t)}`).join("")}
    ${highlights ? `
    核心亮点
      ${highlights}
    ` : ""} ${specs ? `
    关键参数
    ${specs}
    ` : ""}
    适合人群

    ${esc(d.audience || "通用场景")}

    ${d.note ? `
    ${esc(d.note)}
    ` : ""} `; syncModalCompare(); } function syncModalCompare() { const btn = $("#pmCompare"); if (!btn) return; const d = state.modalProduct; const on = !!d && state.selection.includes(d.id); btn.classList.toggle("on", on); btn.textContent = on ? "已加入对比" : "加入对比"; } function closeProduct() { $("#productModal").classList.add("hidden"); stop3d(); } function ensureModelViewer() { const wrap = $("#pmModelViewer"); const src = state.modalProduct?.model3d; if (!wrap || !src) return null; const existing = wrap.querySelector("model-viewer"); if (existing && existing.getAttribute("src") === src) return existing; wrap.innerHTML = ``; return wrap.querySelector("model-viewer"); } function setProductView(view) { document.querySelectorAll(".pm-tab").forEach((t) => t.classList.toggle("active", t.dataset.view === view)); $("#pmView2d").classList.toggle("hidden", view !== "2d"); $("#pmView3d").classList.toggle("hidden", view !== "3d"); if (view === "3d") { if (state.modalProduct?.model3d) { stop3d(); ensureModelViewer(); } else { reset3d(); start3d(); } } else { stop3d(); } } // ---- 3D 交互视图:拖动旋转 / 滚轮缩放 / 双击复位 const view3d = { rotX: 10, rotY: -18, zoom: 1, drag: false, x: 0, y: 0, idleAt: 0, raf: 0, bound: false }; function apply3d() { const stage = $("#stage3d"); if (stage) stage.style.transform = `rotateX(${view3d.rotX}deg) rotateY(${view3d.rotY}deg) scale(${view3d.zoom})`; } function reset3d() { view3d.rotX = 10; view3d.rotY = -18; view3d.zoom = 1; view3d.idleAt = performance.now(); apply3d(); } function bind3d() { if (view3d.bound) return; view3d.bound = true; const box = $("#viewer3d"); box.addEventListener("pointerdown", (e) => { if (e.target.closest(".viewer3d-reset")) return; view3d.drag = true; view3d.x = e.clientX; view3d.y = e.clientY; view3d.idleAt = performance.now(); box.setPointerCapture?.(e.pointerId); }); box.addEventListener("pointermove", (e) => { if (!view3d.drag) return; view3d.rotY += (e.clientX - view3d.x) * 0.5; view3d.rotX = Math.max(-40, Math.min(40, view3d.rotX - (e.clientY - view3d.y) * 0.3)); view3d.x = e.clientX; view3d.y = e.clientY; view3d.idleAt = performance.now(); apply3d(); }); const release = () => { view3d.drag = false; view3d.idleAt = performance.now(); }; box.addEventListener("pointerup", release); box.addEventListener("pointercancel", release); box.addEventListener("pointerleave", release); box.addEventListener( "wheel", (e) => { e.preventDefault(); view3d.zoom = Math.max(0.7, Math.min(1.7, view3d.zoom + (e.deltaY > 0 ? -0.06 : 0.06))); view3d.idleAt = performance.now(); apply3d(); }, { passive: false } ); box.addEventListener("dblclick", reset3d); $("#pm3dReset").addEventListener("click", (e) => { e.stopPropagation(); reset3d(); }); } function start3d() { bind3d(); if (view3d.raf) return; view3d.idleAt = performance.now(); apply3d(); const loop = () => { if ($("#pmView3d").classList.contains("hidden")) { view3d.raf = 0; return; } // 松手 2 秒后恢复缓慢自转 if (view3d.drag) view3d.idleAt = performance.now(); else if (performance.now() - view3d.idleAt > 2000) { view3d.rotY += 0.4; apply3d(); } view3d.raf = requestAnimationFrame(loop); }; view3d.raf = requestAnimationFrame(loop); } function stop3d() { if (view3d.raf) cancelAnimationFrame(view3d.raf); view3d.raf = 0; view3d.drag = false; } // 2D 悬停放大时跟随鼠标定位放大中心 $("#pmStage2d").addEventListener("mousemove", (e) => { const img = $("#pmStage2d img"); if (!img) return; const r = e.currentTarget.getBoundingClientRect(); img.style.transformOrigin = `${((e.clientX - r.left) / r.width) * 100}% ${((e.clientY - r.top) / r.height) * 100}%`; }); document.querySelectorAll(".pm-tab").forEach((tab) => { tab.addEventListener("click", () => setProductView(tab.dataset.view)); }); $("#pmClose").addEventListener("click", closeProduct); $("#productModal").addEventListener("click", (e) => { if (e.target.id === "productModal") closeProduct(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#productModal").classList.contains("hidden")) closeProduct(); }); $("#pmCompare").addEventListener("click", () => { if (state.modalProduct) toggleSelection(state.modalProduct.id); }); $("#pmAsk").addEventListener("click", () => { const d = state.modalProduct; if (!d) return; closeProduct(); send(`介绍一下${d.name}`); }); // ------------------------------------------------------------ 启动 function welcome() { $("#messages").innerHTML = ""; const p = state.profile; const memoryLine = p && p.searchCount ? `已加载你的偏好:${p.summary}` : "还没有历史记录,从下面任意一句开始就行。"; addBotMessage({ markdown: true, reply: "我是小函,你的智能导购助手。\n" + "\n**我能做什么**\n" + "- 按预算 / 场景挑手机、电脑、耳机、家电、美妆、食品\n" + "- 发淘宝、京东链接 → 自动解析参数,并对比 3-5 款同价位竞品\n" + "- 任意两款商品对比,可导出一份 Excel 表格\n" + "\n**怎么输入(三种都行)**\n" + "- 说需求:`3000-4000 元,拍照好一点的手机`\n" + "- 发链接:`https://item.jd.com/100012043978.html`\n" + "- 说对比:`小米 15 和 iPhone 16 哪个好`\n" + "\n**还支持**:方言语音输入、记住你的偏好、联网查库里没有的新机型。\n" + memoryLine, quickReplies: ["3000-4000 元拍照手机", "小米 15 和 iPhone 16 哪个好", "通勤用的降噪耳机"], }); } async function init() { buildQuickGrid(); try { state.config = await api("/api/config"); buildDialectOptions(); const llm = state.config.llm; $("#llmPill").textContent = llm.enabled ? `对话引擎 · ${llm.model}` : `对话引擎 · 本地规则(${state.config.products} 款机型)`; } catch (err) { toast("配置加载失败:" + err.message); } try { const mem = await api(`/api/memory?userId=${state.userId}`); renderProfile(mem.profile); } catch (err) { console.warn(err); } welcome(); await refreshFeed(); } init(); ``` --- ## web/js/voice.js ```javascript // 语音输入:浏览器原生 Web Speech API + 方言模拟降级 const MOCK_SAMPLES = { "": [ "我想买个拍照好的手机,预算四千左右", "小米 15 和 iPhone 16 哪个好", "两千五以内,续航强一点的学生机", ], sichuan: [ "我要个巴适的,拍照巴适、续航又强的,好多钱嘛", "相因点的嘛,三千以内有啥子好推荐", ], yue: [ "我想買部影相靚嘅手機,幾多錢左右啊", "邊隻抵玩啲?三千蚊以內", ], dongbei: [ "整一个嘎嘎好的手机,拍照贼拉好使", "三千以内,啥玩意儿最划算", ], henan: [ "中不中?两千五以内的手机给我推荐一个", "啥价儿嘛,恁说说续航强的哪款", ], shanghai: [ "拍照老灵额手机,几钿啊", "便宜点伐,三千左右适意的", ], }; export class VoiceInput { constructor({ onInterim, onFinal, onState, onError }) { this.onInterim = onInterim || (() => {}); this.onFinal = onFinal || (() => {}); this.onState = onState || (() => {}); this.onError = onError || (() => {}); this.recognition = null; this.active = false; this.finalText = ""; } get supported() { return !!(window.SpeechRecognition || window.webkitSpeechRecognition); } start(lang = "zh-CN") { if (!this.supported) { this.onState("unsupported"); return false; } this.stop(); const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition; const rec = new Ctor(); rec.lang = lang; rec.continuous = false; rec.interimResults = true; rec.maxAlternatives = 1; this.finalText = ""; this.active = true; rec.onstart = () => this.onState("recording"); rec.onresult = (event) => { let interim = ""; for (let i = event.resultIndex; i < event.results.length; i += 1) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { this.finalText += transcript; } else { interim += transcript; } } if (interim) this.onInterim(interim); }; rec.onerror = (event) => { this.active = false; const map = { "not-allowed": "麦克风权限被拒绝,请在浏览器中允许使用麦克风", "service-not-allowed": "当前浏览器不允许使用语音识别服务", "no-speech": "没有听到声音,请再说一次", network: "语音识别网络异常,已切换到模拟输入", "audio-capture": "没有检测到麦克风设备", }; this.onError(map[event.error] || `语音识别出错:${event.error}`); this.onState("idle"); }; rec.onend = () => { this.active = false; this.onState("idle"); const text = this.finalText.trim(); if (text) this.onFinal(text); }; try { rec.start(); this.recognition = rec; return true; } catch (err) { this.active = false; this.onError("语音识别启动失败:" + err.message); return false; } } stop() { if (this.recognition && this.active) { try { this.recognition.stop(); } catch (err) { /* ignore */ } } this.active = false; } mockSample(dialectCode) { const list = MOCK_SAMPLES[dialectCode] || MOCK_SAMPLES[""]; return list[Math.floor(Math.random() * list.length)]; } } // 语音播报 export function speak(text) { if (!("speechSynthesis" in window) || !text) return false; try { window.speechSynthesis.cancel(); const clean = text.replace(/([^)]*)/g, "").replace(/[·•]/g, "").slice(0, 180); const utter = new SpeechSynthesisUtterance(clean); utter.lang = "zh-CN"; utter.rate = 1.03; window.speechSynthesis.speak(utter); return true; } catch (err) { return false; } } ``` --- ## data/products.json ```json [ { "id": "mi15", "brand": "小米", "name": "小米 15", "price": 4499, "originalPrice": 4999, "rating": 98.6, "sales": 128000, "reviews": 32000, "chip": "骁龙 8 至尊版", "screen": "6.36 英寸 1.5K 直屏", "battery": "5400mAh", "charge": "90W 有线 / 50W 无线", "camera": "5000 万徕卡三摄 + 长焦", "weight": "191g", "storage": "12+256GB", "system": "HyperOS 2", "scenes": ["拍照", "小屏", "续航", "游戏"], "tags": ["小屏旗舰", "徕卡影像", "旗舰芯", "口碑好"], "highlights": ["小屏手感一流,单手可握", "徕卡调校,人像出片率高", "5400mAh 大电池 + 90W 快充"], "audience": "喜欢小屏手感、注重拍照的都市用户", "color": "#ff6900", "color2": "#ffb26b" }, { "id": "mi15pro", "brand": "小米", "name": "小米 15 Pro", "price": 5799, "originalPrice": 6499, "rating": 97.8, "sales": 86000, "reviews": 21000, "chip": "骁龙 8 至尊版", "screen": "6.73 英寸 2K 微曲屏", "battery": "6100mAh", "charge": "90W 有线 / 50W 无线", "camera": "徕卡全焦段四摄 + 潜望长焦", "weight": "213g", "storage": "16+512GB", "system": "HyperOS 2", "scenes": ["拍照", "大屏", "续航", "游戏"], "tags": ["影像旗舰", "潜望长焦", "2K 大屏", "高端"], "highlights": ["2K 微曲屏 + 6100mAh 大电池", "潜望长焦,远景拍摄更清晰", "顶配性能,重度游戏稳帧"], "audience": "预算充足、要影像和屏幕都拉满的用户", "color": "#ff6900", "color2": "#e0452b" }, { "id": "mi14", "brand": "小米", "name": "小米 14", "price": 3299, "originalPrice": 3999, "rating": 97.2, "sales": 210000, "reviews": 56000, "chip": "骁龙 8 Gen3", "screen": "6.36 英寸 1.5K 直屏", "battery": "4610mAh", "charge": "90W 有线", "camera": "5000 万徕卡三摄", "weight": "188g", "storage": "12+256GB", "system": "HyperOS 2", "scenes": ["拍照", "小屏", "性价比"], "tags": ["上代旗舰", "小屏", "降价真香", "徕卡"], "highlights": ["上代旗舰清仓,价格直降千元", "小屏 + 徕卡影像依旧能打", "188g 轻巧,久握不累"], "audience": "想用中端价格体验旗舰影像的用户", "color": "#ff6900", "color2": "#c94f1a" }, { "id": "k80", "brand": "小米", "name": "Redmi K80", "price": 2499, "originalPrice": 2799, "rating": 96.5, "sales": 560000, "reviews": 142000, "chip": "骁龙 8 Gen3", "screen": "6.67 英寸 2K 直屏", "battery": "6550mAh", "charge": "90W 有线", "camera": "5000 万主摄 + 超广角", "weight": "206g", "storage": "12+256GB", "system": "HyperOS 2", "scenes": ["性价比", "游戏", "续航", "大屏"], "tags": ["性能怪兽", "性价比之王", "2K 屏", "学生党"], "highlights": ["2499 元买到旗舰芯 + 2K 屏", "6550mAh 大电池,两天一充", "销量口碑双高,闭眼买不易错"], "audience": "预算 2500 左右、要性能和续航的学生党", "color": "#ff6900", "color2": "#a83b12" }, { "id": "turbo4", "brand": "小米", "name": "Redmi Turbo 4", "price": 1999, "originalPrice": 2299, "rating": 95.8, "sales": 430000, "reviews": 98000, "chip": "天玑 8400-Ultra", "screen": "6.67 英寸 1.5K 直屏", "battery": "7550mAh", "charge": "90W 有线", "camera": "5000 万主摄", "weight": "203g", "storage": "12+256GB", "system": "HyperOS 2", "scenes": ["性价比", "续航", "学生"], "tags": ["千元价位", "超长续航", "入门首选"], "highlights": ["2000 元档,7550mAh 巨电池", "日常使用流畅,性价比拉满", "适合作为备用机或学生机"], "audience": "预算有限、看重续航和实用性的用户", "color": "#ff6900", "color2": "#8a2f0e" }, { "id": "mixflip", "brand": "小米", "name": "小米 MIX Flip", "price": 5999, "originalPrice": 6499, "rating": 96.9, "sales": 52000, "reviews": 14000, "chip": "骁龙 8 Gen3", "screen": "6.86 英寸内屏 + 4.01 英寸外屏", "battery": "4780mAh", "charge": "67W 有线", "camera": "5000 万徕卡双摄", "weight": "192g", "storage": "12+512GB", "system": "HyperOS 2", "scenes": ["折叠", "拍照", "轻薄"], "tags": ["小折叠", "时尚", "女性向", "拍照"], "highlights": ["口袋小折叠,颜值担当", "外屏可自拍取景,拍照更方便", "轻至 192g,折叠里算轻的"], "audience": "追求时尚外观与便携的用户", "color": "#ff6900", "color2": "#7d3cff" }, { "id": "ip16", "brand": "苹果", "name": "iPhone 16", "price": 5999, "originalPrice": 5999, "rating": 97.5, "sales": 320000, "reviews": 88000, "chip": "A18", "screen": "6.1 英寸 超视网膜 XDR", "battery": "3561mAh", "charge": "20W 有线 / MagSafe 无线", "camera": "4800 万主摄 + 超广角", "weight": "170g", "storage": "128GB", "system": "iOS 18", "scenes": ["小屏", "系统", "轻薄", "拍照"], "tags": ["生态流畅", "小巧轻便", "拍照色彩准", "保值"], "highlights": ["170g 轻巧,单手握持舒服", "iOS 生态流畅,五年不卡", "相机色彩真实,视频防抖强"], "audience": "习惯 iOS 生态、重视流畅度和小巧手感的用户", "color": "#111111", "color2": "#6b7280" }, { "id": "ip16pro", "brand": "苹果", "name": "iPhone 16 Pro", "price": 7999, "originalPrice": 8999, "rating": 98.1, "sales": 260000, "reviews": 76000, "chip": "A18 Pro", "screen": "6.3 英寸 120Hz ProMotion", "battery": "3582mAh", "charge": "20W 有线 / MagSafe 无线", "camera": "4800 万三摄 + 5 倍长焦", "weight": "199g", "storage": "256GB", "system": "iOS 18", "scenes": ["拍照", "游戏", "系统", "商务"], "tags": ["专业影像", "钛金属", "高刷", "高端商务"], "highlights": ["5 倍潜望长焦 + 4K120 视频", "A18 Pro 性能天花板,游戏稳", "钛金属边框,商务质感"], "audience": "预算充足、要影像和性能都顶配的用户", "color": "#111111", "color2": "#8b7355" }, { "id": "ip15", "brand": "苹果", "name": "iPhone 15", "price": 4699, "originalPrice": 5999, "rating": 96.8, "sales": 480000, "reviews": 150000, "chip": "A16", "screen": "6.1 英寸 超视网膜 XDR", "battery": "3349mAh", "charge": "20W 有线", "camera": "4800 万主摄 + 超广角", "weight": "171g", "storage": "128GB", "system": "iOS 18", "scenes": ["小屏", "系统", "性价比", "轻薄"], "tags": ["降价真香", "生态入门", "小巧", "保值"], "highlights": ["降至 4699,iOS 入门好选择", "灵动岛 + 4800 万主摄", "保值率高,二手也好出手"], "audience": "想低价进入苹果生态的用户", "color": "#111111", "color2": "#9aa3af" }, { "id": "ip16plus", "brand": "苹果", "name": "iPhone 16 Plus", "price": 6999, "originalPrice": 6999, "rating": 96.4, "sales": 120000, "reviews": 34000, "chip": "A18", "screen": "6.7 英寸 超视网膜 XDR", "battery": "4674mAh", "charge": "20W 有线 / MagSafe 无线", "camera": "4800 万主摄 + 超广角", "weight": "199g", "storage": "128GB", "system": "iOS 18", "scenes": ["大屏", "续航", "系统"], "tags": ["大屏", "长续航", "生态"], "highlights": ["6.7 英寸大屏,追剧爽", "4674mAh 续航在 iPhone 里最强", "iOS 生态 + 大屏组合"], "audience": "喜欢大屏、看重续航的苹果用户", "color": "#111111", "color2": "#5b8def" }, { "id": "mate70", "brand": "华为", "name": "华为 Mate 70", "price": 5499, "originalPrice": 5999, "rating": 98.3, "sales": 240000, "reviews": 68000, "chip": "麒麟 9020", "screen": "6.7 英寸 1.5K 直屏", "battery": "5200mAh", "charge": "66W 有线 / 50W 无线", "camera": "红枫原色三摄", "weight": "203g", "storage": "12+256GB", "system": "HarmonyOS NEXT", "scenes": ["商务", "拍照", "续航", "信号"], "tags": ["商务标杆", "鸿蒙", "信号强", "质感"], "highlights": ["鸿蒙 NEXT 流畅且安全", "信号与通话质量出色", "商务场合气场足,拿得出手"], "audience": "商务人士、重视信号与系统安全的用户", "color": "#c8102e", "color2": "#7a1020" }, { "id": "nova13", "brand": "华为", "name": "华为 nova 13", "price": 2699, "originalPrice": 2999, "rating": 96.1, "sales": 310000, "reviews": 86000, "chip": "骁龙 7 Gen3", "screen": "6.7 英寸 1.5K 曲面屏", "battery": "5000mAh", "charge": "100W 有线", "camera": "6000 万前置 + 双摄后置", "weight": "180g", "storage": "12+256GB", "system": "HarmonyOS", "scenes": ["自拍", "轻薄", "性价比"], "tags": ["自拍神器", "轻薄", "高颜值", "学生党"], "highlights": ["6000 万前置,自拍清晰", "180g 轻薄高颜值", "100W 快充,回血快"], "audience": "爱自拍、注重手感和颜值的年轻用户", "color": "#c8102e", "color2": "#f08fa0" }, { "id": "pura70", "brand": "华为", "name": "华为 Pura 70", "price": 4999, "originalPrice": 5499, "rating": 97.4, "sales": 190000, "reviews": 52000, "chip": "麒麟 9010", "screen": "6.6 英寸 1.5K 直屏", "battery": "4900mAh", "charge": "66W 有线 / 50W 无线", "camera": "5000 万超聚光主摄 + 潜望", "weight": "207g", "storage": "12+256GB", "system": "HarmonyOS 4.2", "scenes": ["拍照", "商务", "续航"], "tags": ["影像旗舰", "长焦", "鸿蒙"], "highlights": ["超聚光主摄,夜景能力强", "潜望长焦,拍远也清楚", "鸿蒙生态互联方便"], "audience": "华为生态用户中的摄影爱好者", "color": "#c8102e", "color2": "#de5b6d" }, { "id": "magic7", "brand": "荣耀", "name": "荣耀 Magic 7", "price": 4499, "originalPrice": 4999, "rating": 97.1, "sales": 150000, "reviews": 42000, "chip": "骁龙 8 至尊版", "screen": "6.78 英寸 1.5K 护眼屏", "battery": "5650mAh", "charge": "100W 有线 / 50W 无线", "camera": "5000 万三摄 + 潜望长焦", "weight": "199g", "storage": "12+256GB", "system": "MagicOS 9", "scenes": ["拍照", "续航", "大屏", "办公"], "tags": ["护眼屏", "长焦", "AI 助手", "均衡"], "highlights": ["AI 护眼屏,长时间看更舒服", "5650mAh + 100W 快充", "长焦人像讨喜,拍照均衡"], "audience": "看重护眼、续航与均衡体验的用户", "color": "#1e3a8a", "color2": "#60a5fa" }, { "id": "findx8", "brand": "OPPO", "name": "OPPO Find X8", "price": 4199, "originalPrice": 4699, "rating": 97.3, "sales": 130000, "reviews": 36000, "chip": "天玑 9400", "screen": "6.59 英寸 1.5K 直屏", "battery": "5630mAh", "charge": "80W 有线 / 50W 无线", "camera": "哈苏三摄 + 双潜望", "weight": "193g", "storage": "12+256GB", "system": "ColorOS 15", "scenes": ["拍照", "轻薄", "续航", "游戏"], "tags": ["哈苏影像", "轻薄", "大电池", "人像"], "highlights": ["哈苏人像,肤色自然", "193g 轻薄 + 5630mAh 大电池", "天玑 9400 性能强,发热控制好"], "audience": "喜欢拍人像、要轻薄和大电池的用户", "color": "#0f766e", "color2": "#34d399" }, { "id": "x200", "brand": "vivo", "name": "vivo X200", "price": 4299, "originalPrice": 4799, "rating": 97.6, "sales": 170000, "reviews": 47000, "chip": "天玑 9400", "screen": "6.67 英寸 1.5K 直屏", "battery": "5800mAh", "charge": "90W 有线", "camera": "蔡司三摄 + 潜望长焦", "weight": "197g", "storage": "12+256GB", "system": "OriginOS 5", "scenes": ["拍照", "续航", "大屏", "游戏"], "tags": ["蔡司影像", "长焦", "大电池", "演唱会神器"], "highlights": ["蔡司长焦,拍演唱会一流", "5800mAh 续航扎实", "OriginOS 流畅度高"], "audience": "爱拍远景、追演唱会与旅行的用户", "color": "#1d4ed8", "color2": "#38bdf8" }, { "id": "oneplus13", "brand": "一加", "name": "一加 13", "price": 4499, "originalPrice": 4999, "rating": 97.9, "sales": 98000, "reviews": 27000, "chip": "骁龙 8 至尊版", "screen": "6.82 英寸 2K 东方屏", "battery": "6000mAh", "charge": "100W 有线 / 50W 无线", "camera": "哈苏三摄", "weight": "210g", "storage": "12+256GB", "system": "ColorOS 15", "scenes": ["游戏", "大屏", "续航", "性能"], "tags": ["性能旗舰", "2K 屏", "散热强", "游戏党"], "highlights": ["2K 东方屏 + 6000mAh", "性能释放激进,游戏帧率稳", "100W 快充,半小时回满"], "audience": "重度游戏玩家与性能发烧友", "color": "#1e293b", "color2": "#ef4444" }, { "id": "s25", "brand": "三星", "name": "三星 Galaxy S25", "price": 5999, "originalPrice": 6499, "rating": 95.9, "sales": 45000, "reviews": 12000, "chip": "骁龙 8 至尊版 for Galaxy", "screen": "6.2 英寸 120Hz 直屏", "battery": "4000mAh", "charge": "25W 有线 / 15W 无线", "camera": "5000 万三摄", "weight": "162g", "storage": "12+256GB", "system": "One UI 7", "scenes": ["小屏", "轻薄", "系统"], "tags": ["超轻", "小屏", "AI 功能", "国际品牌"], "highlights": ["162g 超轻,握感极佳", "One UI 功能丰富、可定制", "AI 助手与翻译功能好用"], "audience": "偏爱小屏轻巧与国际品牌的用户", "color": "#0f172a", "color2": "#64748b" } ] ``` --- ## data/catalog.json ```json [ { "id": "mi15", "category": "手机", "brand": "小米", "brandTier": "popular", "name": "小米 15", "price": 4499, "rating": 98.6, "sales": 128000, "tags": [ "小屏旗舰", "徕卡影像", "旗舰芯", "口碑好" ], "highlights": [ "小屏手感一流,单手可握", "徕卡调校,人像出片率高", "5400mAh 大电池 + 90W 快充" ], "audience": "喜欢小屏手感、注重拍照的都市用户", "scenes": [ "拍照", "小屏", "续航", "游戏" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "骁龙 8 至尊版", "屏幕": "6.36 英寸 1.5K 直屏", "电池": "5400mAh", "快充": "90W 有线 / 50W 无线", "影像": "5000 万徕卡三摄 + 长焦", "重量": "191g", "存储": "12+256GB", "系统": "HyperOS 2" }, "color": "#ff6900", "color2": "#ffb26b", "origin": "seed" }, { "id": "mi15pro", "category": "手机", "brand": "小米", "brandTier": "popular", "name": "小米 15 Pro", "price": 5799, "rating": 97.8, "sales": 86000, "tags": [ "影像旗舰", "潜望长焦", "2K 大屏", "高端" ], "highlights": [ "2K 微曲屏 + 6100mAh 大电池", "潜望长焦,远景拍摄更清晰", "顶配性能,重度游戏稳帧" ], "audience": "预算充足、要影像和屏幕都拉满的用户", "scenes": [ "拍照", "大屏", "续航", "游戏" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "骁龙 8 至尊版", "屏幕": "6.73 英寸 2K 微曲屏", "电池": "6100mAh", "快充": "90W 有线 / 50W 无线", "影像": "徕卡全焦段四摄 + 潜望长焦", "重量": "213g", "存储": "16+512GB", "系统": "HyperOS 2" }, "color": "#ff6900", "color2": "#e0452b", "origin": "seed" }, { "id": "mi14", "category": "手机", "brand": "小米", "brandTier": "popular", "name": "小米 14", "price": 3299, "rating": 97.2, "sales": 210000, "tags": [ "上代旗舰", "小屏", "降价真香", "徕卡" ], "highlights": [ "上代旗舰清仓,价格直降千元", "小屏 + 徕卡影像依旧能打", "188g 轻巧,久握不累" ], "audience": "想用中端价格体验旗舰影像的用户", "scenes": [ "拍照", "小屏", "性价比" ], "environments": [ "indoor" ], "specs": { "处理器": "骁龙 8 Gen3", "屏幕": "6.36 英寸 1.5K 直屏", "电池": "4610mAh", "快充": "90W 有线", "影像": "5000 万徕卡三摄", "重量": "188g", "存储": "12+256GB", "系统": "HyperOS 2" }, "color": "#ff6900", "color2": "#c94f1a", "origin": "seed" }, { "id": "k80", "category": "手机", "brand": "小米", "brandTier": "popular", "name": "Redmi K80", "price": 2499, "rating": 96.5, "sales": 560000, "tags": [ "性能怪兽", "性价比之王", "2K 屏", "学生党" ], "highlights": [ "2499 元买到旗舰芯 + 2K 屏", "6550mAh 大电池,两天一充", "销量口碑双高,闭眼买不易错" ], "audience": "预算 2500 左右、要性能和续航的学生党", "scenes": [ "性价比", "游戏", "续航", "大屏" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "骁龙 8 Gen3", "屏幕": "6.67 英寸 2K 直屏", "电池": "6550mAh", "快充": "90W 有线", "影像": "5000 万主摄 + 超广角", "重量": "206g", "存储": "12+256GB", "系统": "HyperOS 2" }, "color": "#ff6900", "color2": "#a83b12", "origin": "seed" }, { "id": "turbo4", "category": "手机", "brand": "小米", "brandTier": "popular", "name": "Redmi Turbo 4", "price": 1999, "rating": 95.8, "sales": 430000, "tags": [ "千元价位", "超长续航", "入门首选" ], "highlights": [ "2000 元档,7550mAh 巨电池", "日常使用流畅,性价比拉满", "适合作为备用机或学生机" ], "audience": "预算有限、看重续航和实用性的用户", "scenes": [ "性价比", "续航", "学生" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "天玑 8400-Ultra", "屏幕": "6.67 英寸 1.5K 直屏", "电池": "7550mAh", "快充": "90W 有线", "影像": "5000 万主摄", "重量": "203g", "存储": "12+256GB", "系统": "HyperOS 2" }, "color": "#ff6900", "color2": "#8a2f0e", "origin": "seed" }, { "id": "mixflip", "category": "手机", "brand": "小米", "brandTier": "popular", "name": "小米 MIX Flip", "price": 5999, "rating": 96.9, "sales": 52000, "tags": [ "小折叠", "时尚", "女性向", "拍照" ], "highlights": [ "口袋小折叠,颜值担当", "外屏可自拍取景,拍照更方便", "轻至 192g,折叠里算轻的" ], "audience": "追求时尚外观与便携的用户", "scenes": [ "折叠", "拍照", "轻薄" ], "environments": [ "indoor" ], "specs": { "处理器": "骁龙 8 Gen3", "屏幕": "6.86 英寸内屏 + 4.01 英寸外屏", "电池": "4780mAh", "快充": "67W 有线", "影像": "5000 万徕卡双摄", "重量": "192g", "存储": "12+512GB", "系统": "HyperOS 2" }, "color": "#ff6900", "color2": "#7d3cff", "origin": "seed" }, { "id": "ip16", "category": "手机", "brand": "苹果", "brandTier": "luxury", "name": "iPhone 16", "price": 5999, "rating": 97.5, "sales": 320000, "tags": [ "生态流畅", "小巧轻便", "拍照色彩准", "保值" ], "highlights": [ "170g 轻巧,单手握持舒服", "iOS 生态流畅,五年不卡", "相机色彩真实,视频防抖强" ], "audience": "习惯 iOS 生态、重视流畅度和小巧手感的用户", "scenes": [ "小屏", "系统", "轻薄", "拍照" ], "environments": [ "indoor" ], "specs": { "处理器": "A18", "屏幕": "6.1 英寸 超视网膜 XDR", "电池": "3561mAh", "快充": "20W 有线 / MagSafe 无线", "影像": "4800 万主摄 + 超广角", "重量": "170g", "存储": "128GB", "系统": "iOS 18" }, "color": "#111111", "color2": "#6b7280", "origin": "seed" }, { "id": "ip16pro", "category": "手机", "brand": "苹果", "brandTier": "luxury", "name": "iPhone 16 Pro", "price": 7999, "rating": 98.1, "sales": 260000, "tags": [ "专业影像", "钛金属", "高刷", "高端商务" ], "highlights": [ "5 倍潜望长焦 + 4K120 视频", "A18 Pro 性能天花板,游戏稳", "钛金属边框,商务质感" ], "audience": "预算充足、要影像和性能都顶配的用户", "scenes": [ "拍照", "游戏", "系统", "商务" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "A18 Pro", "屏幕": "6.3 英寸 120Hz ProMotion", "电池": "3582mAh", "快充": "20W 有线 / MagSafe 无线", "影像": "4800 万三摄 + 5 倍长焦", "重量": "199g", "存储": "256GB", "系统": "iOS 18" }, "color": "#111111", "color2": "#8b7355", "origin": "seed" }, { "id": "ip15", "category": "手机", "brand": "苹果", "brandTier": "luxury", "name": "iPhone 15", "price": 4699, "rating": 96.8, "sales": 480000, "tags": [ "降价真香", "生态入门", "小巧", "保值" ], "highlights": [ "降至 4699,iOS 入门好选择", "灵动岛 + 4800 万主摄", "保值率高,二手也好出手" ], "audience": "想低价进入苹果生态的用户", "scenes": [ "小屏", "系统", "性价比", "轻薄" ], "environments": [ "indoor" ], "specs": { "处理器": "A16", "屏幕": "6.1 英寸 超视网膜 XDR", "电池": "3349mAh", "快充": "20W 有线", "影像": "4800 万主摄 + 超广角", "重量": "171g", "存储": "128GB", "系统": "iOS 18" }, "color": "#111111", "color2": "#9aa3af", "origin": "seed" }, { "id": "ip16plus", "category": "手机", "brand": "苹果", "brandTier": "luxury", "name": "iPhone 16 Plus", "price": 6999, "rating": 96.4, "sales": 120000, "tags": [ "大屏", "长续航", "生态" ], "highlights": [ "6.7 英寸大屏,追剧爽", "4674mAh 续航在 iPhone 里最强", "iOS 生态 + 大屏组合" ], "audience": "喜欢大屏、看重续航的苹果用户", "scenes": [ "大屏", "续航", "系统" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "A18", "屏幕": "6.7 英寸 超视网膜 XDR", "电池": "4674mAh", "快充": "20W 有线 / MagSafe 无线", "影像": "4800 万主摄 + 超广角", "重量": "199g", "存储": "128GB", "系统": "iOS 18" }, "color": "#111111", "color2": "#5b8def", "origin": "seed" }, { "id": "mate70", "category": "手机", "brand": "华为", "brandTier": "luxury", "name": "华为 Mate 70", "price": 5499, "rating": 98.3, "sales": 240000, "tags": [ "商务标杆", "鸿蒙", "信号强", "质感" ], "highlights": [ "鸿蒙 NEXT 流畅且安全", "信号与通话质量出色", "商务场合气场足,拿得出手" ], "audience": "商务人士、重视信号与系统安全的用户", "scenes": [ "商务", "拍照", "续航", "信号" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "麒麟 9020", "屏幕": "6.7 英寸 1.5K 直屏", "电池": "5200mAh", "快充": "66W 有线 / 50W 无线", "影像": "红枫原色三摄", "重量": "203g", "存储": "12+256GB", "系统": "HarmonyOS NEXT" }, "color": "#c8102e", "color2": "#7a1020", "origin": "seed" }, { "id": "nova13", "category": "手机", "brand": "华为", "brandTier": "luxury", "name": "华为 nova 13", "price": 2699, "rating": 96.1, "sales": 310000, "tags": [ "自拍神器", "轻薄", "高颜值", "学生党" ], "highlights": [ "6000 万前置,自拍清晰", "180g 轻薄高颜值", "100W 快充,回血快" ], "audience": "爱自拍、注重手感和颜值的年轻用户", "scenes": [ "自拍", "轻薄", "性价比" ], "environments": [ "indoor" ], "specs": { "处理器": "骁龙 7 Gen3", "屏幕": "6.7 英寸 1.5K 曲面屏", "电池": "5000mAh", "快充": "100W 有线", "影像": "6000 万前置 + 双摄后置", "重量": "180g", "存储": "12+256GB", "系统": "HarmonyOS" }, "color": "#c8102e", "color2": "#f08fa0", "origin": "seed" }, { "id": "pura70", "category": "手机", "brand": "华为", "brandTier": "luxury", "name": "华为 Pura 70", "price": 4999, "rating": 97.4, "sales": 190000, "tags": [ "影像旗舰", "长焦", "鸿蒙" ], "highlights": [ "超聚光主摄,夜景能力强", "潜望长焦,拍远也清楚", "鸿蒙生态互联方便" ], "audience": "华为生态用户中的摄影爱好者", "scenes": [ "拍照", "商务", "续航" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "麒麟 9010", "屏幕": "6.6 英寸 1.5K 直屏", "电池": "4900mAh", "快充": "66W 有线 / 50W 无线", "影像": "5000 万超聚光主摄 + 潜望", "重量": "207g", "存储": "12+256GB", "系统": "HarmonyOS 4.2" }, "color": "#c8102e", "color2": "#de5b6d", "origin": "seed" }, { "id": "magic7", "category": "手机", "brand": "荣耀", "brandTier": "popular", "name": "荣耀 Magic 7", "price": 4499, "rating": 97.1, "sales": 150000, "tags": [ "护眼屏", "长焦", "AI 助手", "均衡" ], "highlights": [ "AI 护眼屏,长时间看更舒服", "5650mAh + 100W 快充", "长焦人像讨喜,拍照均衡" ], "audience": "看重护眼、续航与均衡体验的用户", "scenes": [ "拍照", "续航", "大屏", "办公" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "骁龙 8 至尊版", "屏幕": "6.78 英寸 1.5K 护眼屏", "电池": "5650mAh", "快充": "100W 有线 / 50W 无线", "影像": "5000 万三摄 + 潜望长焦", "重量": "199g", "存储": "12+256GB", "系统": "MagicOS 9" }, "color": "#1e3a8a", "color2": "#60a5fa", "origin": "seed" }, { "id": "findx8", "category": "手机", "brand": "OPPO", "brandTier": "popular", "name": "OPPO Find X8", "price": 4199, "rating": 97.3, "sales": 130000, "tags": [ "哈苏影像", "轻薄", "大电池", "人像" ], "highlights": [ "哈苏人像,肤色自然", "193g 轻薄 + 5630mAh 大电池", "天玑 9400 性能强,发热控制好" ], "audience": "喜欢拍人像、要轻薄和大电池的用户", "scenes": [ "拍照", "轻薄", "续航", "游戏" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "天玑 9400", "屏幕": "6.59 英寸 1.5K 直屏", "电池": "5630mAh", "快充": "80W 有线 / 50W 无线", "影像": "哈苏三摄 + 双潜望", "重量": "193g", "存储": "12+256GB", "系统": "ColorOS 15" }, "color": "#0f766e", "color2": "#34d399", "origin": "seed" }, { "id": "x200", "category": "手机", "brand": "vivo", "brandTier": "popular", "name": "vivo X200", "price": 4299, "rating": 97.6, "sales": 170000, "tags": [ "蔡司影像", "长焦", "大电池", "演唱会神器" ], "highlights": [ "蔡司长焦,拍演唱会一流", "5800mAh 续航扎实", "OriginOS 流畅度高" ], "audience": "爱拍远景、追演唱会与旅行的用户", "scenes": [ "拍照", "续航", "大屏", "游戏" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "天玑 9400", "屏幕": "6.67 英寸 1.5K 直屏", "电池": "5800mAh", "快充": "90W 有线", "影像": "蔡司三摄 + 潜望长焦", "重量": "197g", "存储": "12+256GB", "系统": "OriginOS 5" }, "color": "#1d4ed8", "color2": "#38bdf8", "origin": "seed" }, { "id": "oneplus13", "category": "手机", "brand": "一加", "brandTier": "niche", "name": "一加 13", "price": 4499, "rating": 97.9, "sales": 98000, "tags": [ "性能旗舰", "2K 屏", "散热强", "游戏党" ], "highlights": [ "2K 东方屏 + 6000mAh", "性能释放激进,游戏帧率稳", "100W 快充,半小时回满" ], "audience": "重度游戏玩家与性能发烧友", "scenes": [ "游戏", "大屏", "续航", "性能" ], "environments": [ "indoor", "outdoor" ], "specs": { "处理器": "骁龙 8 至尊版", "屏幕": "6.82 英寸 2K 东方屏", "电池": "6000mAh", "快充": "100W 有线 / 50W 无线", "影像": "哈苏三摄", "重量": "210g", "存储": "12+256GB", "系统": "ColorOS 15" }, "color": "#1e293b", "color2": "#ef4444", "origin": "seed" }, { "id": "s25", "category": "手机", "brand": "三星", "brandTier": "luxury", "name": "三星 Galaxy S25", "price": 5999, "rating": 95.9, "sales": 45000, "tags": [ "超轻", "小屏", "AI 功能", "国际品牌" ], "highlights": [ "162g 超轻,握感极佳", "One UI 功能丰富、可定制", "AI 助手与翻译功能好用" ], "audience": "偏爱小屏轻巧与国际品牌的用户", "scenes": [ "小屏", "轻薄", "系统" ], "environments": [ "indoor" ], "specs": { "处理器": "骁龙 8 至尊版 for Galaxy", "屏幕": "6.2 英寸 120Hz 直屏", "电池": "4000mAh", "快充": "25W 有线 / 15W 无线", "影像": "5000 万三摄", "重量": "162g", "存储": "12+256GB", "系统": "One UI 7" }, "color": "#0f172a", "color2": "#64748b", "origin": "seed" }, { "id": "mbp-air", "category": "笔记本", "brand": "苹果", "brandTier": "luxury", "name": "MacBook Air M3 13英寸", "price": 7999, "rating": 98, "sales": 52000, "tags": [ "轻薄", "办公", "长续航" ], "highlights": [ "M3 芯片,18 小时续航", "1.24kg,无风扇静音" ], "audience": "移动办公与设计人群", "scenes": [ "办公", "轻薄", "续航" ], "environments": [ "indoor" ], "specs": { "处理器": "Apple M3", "屏幕": "13.6 英寸 Liquid 视网膜", "重量": "1.24kg", "续航": "约 18 小时" }, "color": "#3a3f4b", "color2": "#7d8497", "origin": "seed" }, { "id": "lenovo-xiaoxin", "category": "笔记本", "brand": "联想", "brandTier": "popular", "name": "小新 Pro 16 2025", "price": 5499, "rating": 95, "sales": 86000, "tags": [ "性能", "大屏", "性价比" ], "highlights": [ "2.5K 165Hz 高刷屏", "锐龙 7 处理器,整机 1.89kg" ], "audience": "学生与全能办公", "scenes": [ "办公", "游戏", "性能" ], "environments": [ "indoor" ], "specs": { "处理器": "锐龙 7 8845H", "屏幕": "16 英寸 2.5K 165Hz", "重量": "1.89kg", "续航": "约 8 小时" }, "color": "#1f56c4", "color2": "#6d94e8", "origin": "seed" }, { "id": "matebook-xpro", "category": "笔记本", "brand": "华为", "brandTier": "luxury", "name": "MateBook X Pro 2025", "price": 9999, "rating": 97, "sales": 21000, "tags": [ "轻薄", "商务", "高奢" ], "highlights": [ "1.0kg 超轻机身", "3.1K OLED 触控屏" ], "audience": "高端商务人士", "scenes": [ "办公", "轻薄", "商务" ], "environments": [ "indoor" ], "specs": { "处理器": "酷睿 Ultra 7", "屏幕": "14.2 英寸 3.1K OLED", "重量": "1.0kg", "续航": "约 10 小时" }, "color": "#c8102e", "color2": "#ff6b7f", "origin": "seed" }, { "id": "dell-xps13", "category": "笔记本", "brand": "戴尔", "brandTier": "popular", "name": "XPS 13 Plus", "price": 8999, "rating": 94, "sales": 18000, "tags": [ "轻薄", "商务" ], "highlights": [ "无边框键盘设计", "13 代酷睿 + 2.8K 屏" ], "audience": "商务出差人群", "scenes": [ "办公", "轻薄" ], "environments": [ "indoor" ], "specs": { "处理器": "酷睿 i7-1360P", "屏幕": "13.4 英寸 2.8K", "重量": "1.26kg", "续航": "约 9 小时" }, "color": "#0b6bbd", "color2": "#63a6de", "origin": "seed" }, { "id": "rog-16", "category": "笔记本", "brand": "ROG", "brandTier": "niche", "name": "幻 16 星空版", "price": 13999, "rating": 96, "sales": 9600, "tags": [ "游戏", "性能", "大屏" ], "highlights": [ "RTX 4070 独显", "2.5K 240Hz 电竞屏" ], "audience": "硬核游戏玩家", "scenes": [ "游戏", "性能" ], "environments": [ "indoor" ], "specs": { "处理器": "酷睿 i9-14900HX", "显卡": "RTX 4070", "屏幕": "16 英寸 2.5K 240Hz", "重量": "1.9kg" }, "color": "#c8102e", "color2": "#ff6b7f", "origin": "seed" }, { "id": "mec-rev", "category": "笔记本", "brand": "机械革命", "brandTier": "economy", "name": "极光 X 15", "price": 4299, "rating": 91, "sales": 33000, "tags": [ "性价比", "游戏" ], "highlights": [ "RTX 4060 独显", "144Hz 高刷" ], "audience": "预算有限的游戏用户", "scenes": [ "游戏", "性价比" ], "environments": [ "indoor" ], "specs": { "处理器": "酷睿 i7-13620H", "显卡": "RTX 4060", "屏幕": "15.6 英寸 144Hz", "重量": "2.1kg" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "ipad-air", "category": "平板", "brand": "苹果", "brandTier": "luxury", "name": "iPad Air M2 11英寸", "price": 4799, "rating": 98, "sales": 64000, "tags": [ "大屏", "办公", "系统" ], "highlights": [ "M2 芯片,支持二代笔", "10.9 英寸全面屏" ], "audience": "学生与轻办公", "scenes": [ "办公", "大屏", "系统" ], "environments": [ "indoor" ], "specs": { "处理器": "Apple M2", "屏幕": "11 英寸全面屏", "重量": "462g", "续航": "约 10 小时" }, "color": "#3a3f4b", "color2": "#7d8497", "origin": "seed" }, { "id": "mi-pad7", "category": "平板", "brand": "小米", "brandTier": "popular", "name": "小米平板 7 Pro", "price": 2499, "rating": 96, "sales": 43000, "tags": [ "大屏", "性价比", "办公" ], "highlights": [ "3.2K 144Hz 屏", "骁龙 8s Gen3" ], "audience": "影音与网课用户", "scenes": [ "大屏", "办公", "性价比" ], "environments": [ "indoor" ], "specs": { "处理器": "骁龙 8s Gen3", "屏幕": "11.2 英寸 3.2K 144Hz", "重量": "500g", "续航": "约 9 小时" }, "color": "#ff6900", "color2": "#ffb26b", "origin": "seed" }, { "id": "matepad-pro", "category": "平板", "brand": "华为", "brandTier": "luxury", "name": "MatePad Pro 13.2", "price": 5299, "rating": 97, "sales": 15000, "tags": [ "大屏", "办公", "商务" ], "highlights": [ "13.2 英寸 OLED", "星闪手写笔" ], "audience": "专业办公与绘画", "scenes": [ "办公", "大屏", "商务" ], "environments": [ "indoor" ], "specs": { "处理器": "麒麟 9000s", "屏幕": "13.2 英寸 OLED", "重量": "580g", "续航": "约 8 小时" }, "color": "#c8102e", "color2": "#ff6b7f", "origin": "seed" }, { "id": "honor-pad", "category": "平板", "brand": "荣耀", "brandTier": "popular", "name": "荣耀平板 V9", "price": 1599, "rating": 94, "sales": 29000, "tags": [ "性价比", "大屏" ], "highlights": [ "12.1 英寸 2.5K 护眼屏", "八扬声器" ], "audience": "学生网课", "scenes": [ "大屏", "性价比", "学生" ], "environments": [ "indoor" ], "specs": { "处理器": "骁龙 7 Gen3", "屏幕": "12.1 英寸 2.5K", "重量": "555g", "续航": "约 10 小时" }, "color": "#0aa1e0", "color2": "#69d0ff", "origin": "seed" }, { "id": "airpods-pro", "category": "耳机", "brand": "苹果", "brandTier": "luxury", "name": "AirPods Pro 2", "price": 1899, "rating": 98, "sales": 210000, "tags": [ "降噪", "通勤", "系统" ], "highlights": [ "自适应降噪", "空间音频" ], "audience": "通勤与苹果生态用户", "scenes": [ "通勤", "音乐", "降噪" ], "environments": [ "indoor", "outdoor" ], "specs": { "类型": "入耳式", "降噪": "自适应主动降噪", "续航": "6+30 小时", "重量": "5.3g/只" }, "color": "#3a3f4b", "color2": "#7d8497", "origin": "seed" }, { "id": "sony-xm5", "category": "耳机", "brand": "索尼", "brandTier": "luxury", "name": "WF-1000XM5", "price": 1999, "rating": 97, "sales": 76000, "tags": [ "降噪", "音质" ], "highlights": [ "业界顶级降噪", "LDAC 高解析音质" ], "audience": "对音质挑剔的用户", "scenes": [ "音乐", "降噪", "通勤" ], "environments": [ "indoor", "outdoor" ], "specs": { "类型": "入耳式", "降噪": "主动降噪", "续航": "8+24 小时", "重量": "5.9g/只" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "shokz-openrun", "category": "耳机", "brand": "韶音", "brandTier": "niche", "name": "OpenRun Pro 2", "price": 1298, "rating": 96, "sales": 58000, "tags": [ "运动", "户外", "骨传导" ], "highlights": [ "不入耳佩戴,跑步稳固", "IP55 防水" ], "audience": "跑步与骑行爱好者", "scenes": [ "运动", "户外" ], "environments": [ "outdoor" ], "specs": { "类型": "骨传导", "防水": "IP55", "续航": "12 小时", "重量": "29g" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "edifier-lolli", "category": "耳机", "brand": "漫步者", "brandTier": "popular", "name": "LolliPods Pro 3", "price": 299, "rating": 93, "sales": 320000, "tags": [ "性价比", "降噪" ], "highlights": [ "-42dB 主动降噪", "百元价位蓝牙 5.3" ], "audience": "学生党与性价比用户", "scenes": [ "音乐", "降噪", "性价比" ], "environments": [ "indoor", "outdoor" ], "specs": { "类型": "入耳式", "降噪": "-42dB", "续航": "5+20 小时", "重量": "4.5g/只" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "bose-ultra", "category": "耳机", "brand": "Bose", "brandTier": "luxury", "name": "QuietComfort Ultra", "price": 2799, "rating": 97, "sales": 24000, "tags": [ "高奢", "降噪", "音质" ], "highlights": [ "沉浸式空间音频", "佩戴极舒适" ], "audience": "高端通勤人群", "scenes": [ "音乐", "降噪", "商务" ], "environments": [ "indoor", "outdoor" ], "specs": { "类型": "头戴式", "降噪": "自适应", "续航": "24 小时", "重量": "250g" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "watch-s10", "category": "智能穿戴", "brand": "苹果", "brandTier": "luxury", "name": "Apple Watch Series 10", "price": 2999, "rating": 98, "sales": 88000, "tags": [ "健康", "系统", "户外" ], "highlights": [ "全天候血氧心率监测", "运动圆环" ], "audience": "健康管理与运动人群", "scenes": [ "健康", "运动", "系统" ], "environments": [ "indoor", "outdoor" ], "specs": { "屏幕": "1.96 英寸 OLED", "健康监测": "心率/血氧/睡眠", "续航": "18 小时", "防水": "50 米" }, "color": "#3a3f4b", "color2": "#7d8497", "origin": "seed" }, { "id": "watch-gt5", "category": "智能穿戴", "brand": "华为", "brandTier": "luxury", "name": "华为 Watch GT5", "price": 1488, "rating": 96, "sales": 120000, "tags": [ "续航", "健康", "户外" ], "highlights": [ "14 天超长续航", "越野跑模式" ], "audience": "长续航运动用户", "scenes": [ "健康", "运动", "续航" ], "environments": [ "indoor", "outdoor" ], "specs": { "屏幕": "1.43 英寸 AMOLED", "健康监测": "心率/血氧/压力", "续航": "14 天", "重量": "35g" }, "color": "#c8102e", "color2": "#ff6b7f", "origin": "seed" }, { "id": "mi-band9", "category": "智能穿戴", "brand": "小米", "brandTier": "popular", "name": "小米手环 9", "price": 249, "rating": 95, "sales": 460000, "tags": [ "性价比", "健康" ], "highlights": [ "21 天续航", "150+ 运动模式" ], "audience": "入门健康监测", "scenes": [ "健康", "性价比" ], "environments": [ "indoor", "outdoor" ], "specs": { "屏幕": "1.62 英寸 AMOLED", "健康监测": "心率/睡眠", "续航": "21 天", "重量": "15g" }, "color": "#ff6900", "color2": "#ffb26b", "origin": "seed" }, { "id": "garmin-265", "category": "智能穿戴", "brand": "佳明", "brandTier": "niche", "name": "Forerunner 265", "price": 2680, "rating": 97, "sales": 13000, "tags": [ "户外", "运动", "小众" ], "highlights": [ "专业跑步训练建议", "多频 GPS" ], "audience": "严肃跑者与越野爱好者", "scenes": [ "运动", "户外" ], "environments": [ "outdoor" ], "specs": { "屏幕": "1.3 英寸 AMOLED", "GPS": "多频多星", "续航": "13 天", "重量": "47g" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "dyson-hd15", "category": "家电", "brand": "戴森", "brandTier": "luxury", "name": "戴森 Supersonic HD15 吹风机", "price": 3399, "rating": 97, "sales": 62000, "tags": [ "高奢", "个护", "居家" ], "highlights": [ "智能温控不伤发", "五种风嘴" ], "audience": "追求护发体验的用户", "scenes": [ "居家", "个护" ], "environments": [ "indoor" ], "specs": { "类型": "高速吹风机", "功率": "1600W", "风速": "13 万转/分钟", "重量": "560g" }, "color": "#8b2fbf", "color2": "#c47ce8", "origin": "seed" }, { "id": "midea-ac", "category": "家电", "brand": "美的", "brandTier": "popular", "name": "美的 1.5 匹新一级变频空调", "price": 2699, "rating": 95, "sales": 180000, "tags": [ "居家", "省电", "热门" ], "highlights": [ "新一级能效", "无声睡眠模式" ], "audience": "卧室与客厅装机", "scenes": [ "居家", "省电" ], "environments": [ "indoor" ], "specs": { "类型": "壁挂式", "匹数": "1.5 匹", "能效": "新一级", "适用面积": "15-23㎡" }, "color": "#1f8ad6", "color2": "#74bdf0", "origin": "seed" }, { "id": "gree-ac", "category": "家电", "brand": "格力", "brandTier": "popular", "name": "格力 云佳 1.5 匹空调", "price": 2899, "rating": 96, "sales": 210000, "tags": [ "居家", "热门", "耐用" ], "highlights": [ "双级增焓压缩机", "自清洁" ], "audience": "看重耐用与售后的家庭", "scenes": [ "居家", "耐用" ], "environments": [ "indoor" ], "specs": { "类型": "壁挂式", "匹数": "1.5 匹", "能效": "一级", "适用面积": "16-20㎡" }, "color": "#0e63a8", "color2": "#5ea3d8", "origin": "seed" }, { "id": "haier-fridge", "category": "家电", "brand": "海尔", "brandTier": "popular", "name": "海尔 501L 十字对开门冰箱", "price": 4299, "rating": 96, "sales": 96000, "tags": [ "居家", "大容量", "热门" ], "highlights": [ "干湿分储", "一级能效" ], "audience": "三口以上家庭", "scenes": [ "居家", "大容量" ], "environments": [ "indoor" ], "specs": { "类型": "十字对开门", "容量": "501L", "能效": "一级", "尺寸": "830×1900mm" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "roborock-s8", "category": "家电", "brand": "石头", "brandTier": "popular", "name": "石头 P10S Pro 扫地机器人", "price": 3299, "rating": 94, "sales": 74000, "tags": [ "居家", "智能", "热门" ], "highlights": [ "双机械臂贴边清扫", "自动集尘" ], "audience": "养宠物与上班族", "scenes": [ "居家", "智能" ], "environments": [ "indoor" ], "specs": { "类型": "扫拖一体", "吸力": "11000Pa", "水箱": "自动补水", "适用面积": "120㎡" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "joyoung-soy", "category": "家电", "brand": "九阳", "brandTier": "economy", "name": "九阳破壁豆浆机", "price": 399, "rating": 93, "sales": 150000, "tags": [ "居家", "性价比", "厨房" ], "highlights": [ "免滤免煮", "一键清洗" ], "audience": "早餐党与家庭厨房", "scenes": [ "居家", "厨房", "性价比" ], "environments": [ "indoor" ], "specs": { "类型": "破壁机", "容量": "1.2L", "功率": "800W", "功能": "豆浆/米糊/果汁" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "sk2-facial", "category": "美妆", "brand": "SK-II", "brandTier": "luxury", "name": "SK-II 神仙水 230ml", "price": 1540, "rating": 97, "sales": 88000, "tags": [ "高奢", "护肤", "口碑" ], "highlights": [ "PITERA 精华", "稳定肤况" ], "audience": "轻熟龄护肤人群", "scenes": [ "护肤", "高奢" ], "environments": [ "indoor" ], "specs": { "容量": "230ml", "适合肤质": "混合/油皮", "核心成分": "PITERA", "质地": "水状精华" }, "color": "#b8860b", "color2": "#e3c26b", "origin": "seed" }, { "id": "lancome-serum", "category": "美妆", "brand": "兰蔻", "brandTier": "luxury", "name": "兰蔻小黑瓶精华 50ml", "price": 1080, "rating": 96, "sales": 120000, "tags": [ "高奢", "护肤", "抗初老" ], "highlights": [ "二裂酵母发酵物", "修护屏障" ], "audience": "25+ 抗初老", "scenes": [ "护肤", "抗初老" ], "environments": [ "indoor" ], "specs": { "容量": "50ml", "适合肤质": "全肤质", "核心成分": "二裂酵母", "质地": "蛋清精华" }, "color": "#c2185b", "color2": "#ff7aa8", "origin": "seed" }, { "id": "proya-serum", "category": "美妆", "brand": "珀莱雅", "brandTier": "popular", "name": "珀莱雅红宝石精华 2.0", "price": 249, "rating": 95, "sales": 260000, "tags": [ "护肤", "性价比", "抗老" ], "highlights": [ "六胜肽抗皱", "国货口碑爆款" ], "audience": "学生与初抗老人群", "scenes": [ "护肤", "性价比", "抗老" ], "environments": [ "indoor" ], "specs": { "容量": "30ml", "适合肤质": "混合/干皮", "核心成分": "六胜肽", "质地": "轻盈精华" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "huaxizi-powder", "category": "美妆", "brand": "花西子", "brandTier": "niche", "name": "花西子空气蜜粉", "price": 169, "rating": 93, "sales": 98000, "tags": [ "彩妆", "国风", "小众" ], "highlights": [ "控油定妆", "国风雕花包装" ], "audience": "喜欢国风彩妆的年轻女生", "scenes": [ "彩妆", "定妆" ], "environments": [ "indoor", "outdoor" ], "specs": { "容量": "8.5g", "适合肤质": "油皮/混油", "妆效": "哑光控油", "色号": "自然色" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "perfect-diary", "category": "美妆", "brand": "完美日记", "brandTier": "popular", "name": "完美日记小细跟口红", "price": 89, "rating": 91, "sales": 210000, "tags": [ "彩妆", "性价比" ], "highlights": [ "丝绒质地", "显白显气色" ], "audience": "学生党与入门彩妆", "scenes": [ "彩妆", "性价比" ], "environments": [ "indoor", "outdoor" ], "specs": { "容量": "3.5g", "质地": "丝绒哑光", "色号": "多色可选", "持妆": "约 6 小时" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "squirrel-nuts", "category": "食品", "brand": "三只松鼠", "brandTier": "popular", "name": "三只松鼠每日坚果 750g", "price": 129, "rating": 95, "sales": 320000, "tags": [ "零食", "健康", "热门" ], "highlights": [ "30 小袋独立装", "六种坚果果干" ], "audience": "上班族加餐", "scenes": [ "零食", "健康" ], "environments": [ "indoor", "outdoor" ], "specs": { "规格": "750g/30 袋", "口味": "原味混合", "保质期": "180 天", "储存": "阴凉干燥" }, "color": "#c98a2b", "color2": "#e8c07a", "origin": "seed" }, { "id": "baixiang-noodle", "category": "食品", "brand": "白象", "brandTier": "economy", "name": "白象汤好喝老母鸡汤面", "price": 39.9, "rating": 96, "sales": 560000, "tags": [ "速食", "性价比", "国货" ], "highlights": [ "真鸡汤底", "非油炸面饼" ], "audience": "宿舍与加班速食", "scenes": [ "速食", "性价比" ], "environments": [ "indoor" ], "specs": { "规格": "5 连包", "口味": "老母鸡汤", "保质期": "6 个月", "做法": "煮/泡" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "genki-soda", "category": "食品", "brand": "元气森林", "brandTier": "popular", "name": "元气森林白桃味气泡水", "price": 55, "rating": 92, "sales": 680000, "tags": [ "饮料", "零糖", "热门" ], "highlights": [ "0 糖 0 脂 0 卡", "白桃清爽口味" ], "audience": "控糖与年轻人群", "scenes": [ "饮料", "零糖" ], "environments": [ "indoor", "outdoor" ], "specs": { "规格": "480ml×15 瓶", "口味": "白桃", "保质期": "9 个月", "糖分": "0 糖" }, "color": "#2bb673", "color2": "#7ce0ad", "origin": "seed" }, { "id": "wuchang-rice", "category": "食品", "brand": "五常", "brandTier": "popular", "name": "五常稻花香大米 5kg", "price": 89, "rating": 97, "sales": 290000, "tags": [ "主食", "性价比", "家庭" ], "highlights": [ "当季新米", "稻花香 2 号" ], "audience": "家庭日常主食", "scenes": [ "主食", "家庭" ], "environments": [ "indoor" ], "specs": { "规格": "5kg", "产地": "黑龙江五常", "品种": "稻花香 2 号", "保质期": "12 个月" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "yili-milk", "category": "食品", "brand": "伊利", "brandTier": "popular", "name": "伊利金典有机纯牛奶", "price": 79, "rating": 96, "sales": 410000, "tags": [ "乳品", "健康", "礼盒" ], "highlights": [ "有机认证", "3.8g 乳蛋白" ], "audience": "家庭早餐与送礼", "scenes": [ "乳品", "健康" ], "environments": [ "indoor", "outdoor" ], "specs": { "规格": "250ml×12 盒", "蛋白质": "3.8g/100ml", "保质期": "6 个月", "储存": "常温" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "arcteryx-jacket", "category": "服饰", "brand": "始祖鸟", "brandTier": "luxury", "name": "始祖鸟 Beta LT 冲锋衣", "price": 4500, "rating": 98, "sales": 28000, "tags": [ "高奢", "户外", "防风" ], "highlights": [ "GORE-TEX 面料", "轻量三层压胶" ], "audience": "户外徒步与通勤", "scenes": [ "户外", "防风", "高奢" ], "environments": [ "outdoor" ], "specs": { "材质": "GORE-TEX 三层", "防水": "20000mm", "重量": "395g", "适用季节": "春秋冬" }, "color": "#1c3d2b", "color2": "#5f8d76", "origin": "seed" }, { "id": "uniqlo-fleece", "category": "服饰", "brand": "优衣库", "brandTier": "economy", "name": "优衣库摇粒绒外套", "price": 199, "rating": 94, "sales": 780000, "tags": [ "性价比", "保暖", "居家" ], "highlights": [ "轻便保暖", "多色可选" ], "audience": "学生与通勤日常", "scenes": [ "保暖", "性价比" ], "environments": [ "indoor", "outdoor" ], "specs": { "材质": "聚酯纤维摇粒绒", "适用季节": "秋冬", "尺码": "S-XXL", "版型": "宽松" }, "color": "#e60012", "color2": "#ff6b6b", "origin": "seed" }, { "id": "anta-shoes", "category": "服饰", "brand": "安踏", "brandTier": "popular", "name": "安踏氢跑 5 跑步鞋", "price": 299, "rating": 93, "sales": 520000, "tags": [ "运动", "性价比", "热门" ], "highlights": [ "轻量回弹中底", "透气网面" ], "audience": "日常慢跑与健走", "scenes": [ "运动", "跑步", "性价比" ], "environments": [ "outdoor" ], "specs": { "材质": "网面+EVA", "重量": "约 230g", "适用场景": "慢跑/健走", "尺码": "36-45" }, "color": "#d81e06", "color2": "#ff6b57", "origin": "seed" }, { "id": "neiwai-bra", "category": "服饰", "brand": "内外", "brandTier": "niche", "name": "内外零感无尺码内衣", "price": 169, "rating": 95, "sales": 190000, "tags": [ "舒适", "小众", "贴身" ], "highlights": [ "无钢圈零感", "无尺码适配" ], "audience": "追求舒适的女性", "scenes": [ "舒适", "贴身" ], "environments": [ "indoor" ], "specs": { "材质": "锦纶+氨纶", "版型": "无钢圈", "尺码": "均码", "适用季节": "四季" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "ikea-desk", "category": "家居", "brand": "宜家", "brandTier": "popular", "name": "宜家贝肯特书桌 120×60", "price": 499, "rating": 92, "sales": 210000, "tags": [ "性价比", "办公", "居家" ], "highlights": [ "可调节桌腿", "简约白色" ], "audience": "居家办公与学生", "scenes": [ "办公", "居家", "性价比" ], "environments": [ "indoor" ], "specs": { "材质": "刨花板贴面", "尺寸": "120×60cm", "风格": "简约", "承重": "50kg" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "yuanshi-table", "category": "家居", "brand": "源氏木语", "brandTier": "popular", "name": "源氏木语实木餐桌 1.4m", "price": 1899, "rating": 95, "sales": 86000, "tags": [ "实木", "居家", "热门" ], "highlights": [ "北美白橡木", "环保水性漆" ], "audience": "注重环保的家庭", "scenes": [ "居家", "耐用" ], "environments": [ "indoor" ], "specs": { "材质": "白橡木", "尺寸": "140×80cm", "风格": "北欧", "工艺": "榫卯" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "linshi-sofa", "category": "家居", "brand": "林氏家居", "brandTier": "popular", "name": "林氏家居科技布沙发 三人位", "price": 2699, "rating": 93, "sales": 63000, "tags": [ "居家", "舒适", "热门" ], "highlights": [ "科技布防污", "高回弹海绵" ], "audience": "小户型与租房改造", "scenes": [ "居家", "舒适" ], "environments": [ "indoor" ], "specs": { "材质": "科技布", "尺寸": "200×90cm", "风格": "现代简约", "填充": "高回弹海绵" }, "color": "#5b6b8c", "color2": "#9aa8c2", "origin": "seed" }, { "id": "guanxia-candle", "category": "家居", "brand": "观夏", "brandTier": "niche", "name": "观夏昆仑煮雪香薰蜡烛", "price": 328, "rating": 96, "sales": 42000, "tags": [ "香薰", "小众", "礼赠" ], "highlights": [ "雪松木质调", "手工浇筑" ], "audience": "居家氛围与送礼", "scenes": [ "香薰", "礼赠", "居家" ], "environments": [ "indoor" ], "specs": { "容量": "220g", "香调": "雪松木质", "燃烧时长": "约 45 小时", "材质": "大豆蜡" }, "color": "#5a4b3c", "color2": "#a3917f", "origin": "seed" } ] ``` --- ## data/dialects.json ```json { "regions": [ { "code": "sichuan", "name": "四川话", "speechLang": "zh-CN", "markers": ["巴适", "安逸", "要得", "相因", "莫得", "好多钱", "撇脱", "板板", "瓜", "雄起", "撇", "整一个"], "phrases": { "巴适": "好用", "安逸": "舒服", "要得": "可以", "相因": "便宜", "莫得": "没有", "好多钱": "多少钱", "撇脱": "方便", "板板": "钱", "耍游戏": "玩游戏", "拍照巴适": "拍照好用", "瓜": "傻", "雄起": "加油", "整一个": "买一个", "千值万值": "非常划算", "相因点": "便宜点", "好久": "多久" } }, { "code": "yue", "name": "粤语", "speechLang": "yue-Hant-HK", "markers": ["幾多錢", "几多钱", "抵玩", "影相", "打機", "打机", "邊隻", "边只", "唔該", "唔该", "靚", "靓", "大芒", "電量", "电量", "好唔好"], "phrases": { "幾多錢": "多少钱", "几多钱": "多少钱", "抵玩": "性价比高", "影相": "拍照", "打機": "玩游戏", "打机": "玩游戏", "邊隻好": "哪个好", "边只好": "哪个好", "邊隻": "哪个", "边只": "哪个", "唔該": "谢谢", "唔该": "谢谢", "靚": "漂亮", "靓": "漂亮", "大芒": "大屏", "電量": "电量", "好唔好": "好不好", "幾錢": "多少钱", "几钱": "多少钱", "抵": "划算", "貴": "贵", "贵": "贵", "影相靚": "拍照好看" } }, { "code": "dongbei", "name": "东北话", "speechLang": "zh-CN", "markers": ["咋整", "整一个", "老铁", "得劲", "嘎嘎", "贼便宜", "啥玩意儿", "干哈", "嗷嗷", "杠杠的"], "phrases": { "咋整": "怎么办", "整一个": "买一个", "老铁": "朋友", "得劲": "好用", "嘎嘎好": "非常好", "嘎嘎": "非常", "贼便宜": "很便宜", "贼": "很", "啥玩意儿": "什么", "干哈": "干什么", "嗷嗷": "非常", "杠杠的": "很靠谱", "便宜点儿": "便宜一点" } }, { "code": "henan", "name": "河南话", "speechLang": "zh-CN", "markers": ["中不中", "啥价儿", "恁", "阵中", "真中", "可便宜", "咋说"], "phrases": { "中不中": "行不行", "啥价儿": "多少钱", "恁": "你", "得劲": "好用", "阵中": "真行", "真中": "真好", "咋说": "怎么卖", "可便宜": "很便宜", "可": "很" } }, { "code": "shanghai", "name": "上海话", "speechLang": "zh-CN", "markers": ["几钿", "老灵", "灵额", "辰光", "白相", "适意"], "phrases": { "几钿": "多少钱", "老灵额": "很好", "灵额": "很好", "老灵": "很好", "辰光": "时候", "白相": "玩", "适意": "舒服", "便宜点伐": "便宜点吗" } } ], "speechLanguages": [ { "lang": "zh-CN", "label": "普通话" }, { "lang": "yue-Hant-HK", "label": "粤语" }, { "lang": "zh-TW", "label": "台湾国语" }, { "lang": "zh-CN", "label": "四川话", "dialect": "sichuan" }, { "lang": "zh-CN", "label": "东北话", "dialect": "dongbei" }, { "lang": "zh-CN", "label": "河南话", "dialect": "henan" }, { "lang": "zh-CN", "label": "上海话", "dialect": "shanghai" }, { "lang": "en-US", "label": "English" } ] } ```