#!/usr/bin/env bash set -euo pipefail keyword=${1:-电脑桌} report_scope=${2:-淘宝口径} workdir=/tmp/market-brief mkdir -p "$workdir" profile=${BROWSER_PROFILE:-openclaw} capture_snapshot() { local page="$1" local url="$2" local wait_text="${3:-}" local limit="${4:-260}" yuce-browser open "$url" >/dev/null 2>&1 || true openclaw browser wait --browser-profile "$profile" --load domcontentloaded --timeout-ms 15000 >/dev/null 2>&1 || true openclaw browser wait --browser-profile "$profile" --time 2000 >/dev/null 2>&1 || true if [ -n "$wait_text" ]; then openclaw browser wait --browser-profile "$profile" --text "$wait_text" --timeout-ms 12000 >/dev/null 2>&1 || true fi openclaw browser snapshot --browser-profile "$profile" --labels --limit "$limit" > "$workdir/${page}.snapshot.txt" || true } # Multi-page evidence collection declare -A PAGE_URLS PAGE_URLS[leaf_category]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_TAPE_ANALYSIS/M_TB_TAPE_ANALYSIS_0/report-view/CZqcPFPRsZ' PAGE_URLS[ranking_list]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_RANKING_LIST/M_TB_RANKING_LIST_0/report-view/hEq4a98B7B' PAGE_URLS[industry_distribution]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_INSIGHT/M_TB_INDUSTRY_DISTRIBUTION/report-view/sfQrjDMn5l' PAGE_URLS[surging_rank]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_SURGING_RANK/M_TB_SURGING_RANK_0/report-view/CD6nSlf6pC' LOGIN_URL='https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F' HOME_URL='https://hq.yucekj.com/#/home?tab=TAOBAO_MNO' yuce-browser open "$LOGIN_URL" >/dev/null 2>&1 || true yuce-browser open "$HOME_URL" >/dev/null 2>&1 || true openclaw browser wait --browser-profile "$profile" --time 1500 >/dev/null 2>&1 || true capture_snapshot leaf_category "${PAGE_URLS[leaf_category]}" "销售金额" 360 capture_snapshot ranking_list "${PAGE_URLS[ranking_list]}" "榜单" 260 capture_snapshot industry_distribution "${PAGE_URLS[industry_distribution]}" "行业" 220 capture_snapshot surging_rank "${PAGE_URLS[surging_rank]}" "飙升" 280 yuce-browser requests --filter yucekj.com > "$workdir/requests.txt" || true python3 - "$workdir" "$keyword" "$report_scope" <<'PY' import json import re import sys from collections import Counter from datetime import datetime from pathlib import Path workdir = Path(sys.argv[1]) keyword = (sys.argv[2] or "").strip() scope = (sys.argv[3] or "").strip() pages = { "leaf_category": workdir / "leaf_category.snapshot.txt", "ranking_list": workdir / "ranking_list.snapshot.txt", "industry_distribution": workdir / "industry_distribution.snapshot.txt", "surging_rank": workdir / "surging_rank.snapshot.txt", } page_text = {k: (p.read_text(encoding="utf-8", errors="ignore") if p.exists() else "") for k, p in pages.items()} request_text = (workdir / "requests.txt").read_text(encoding="utf-8", errors="ignore") if (workdir / "requests.txt").exists() else "" ROW_RE = re.compile(r'row "([^\"]+)"') PCT_RE = re.compile(r'^-?\d+(?:\.\d+)?%$') def norm(s: str) -> str: return re.sub(r"[\s/()()【】\[\]·\-_,,。::;;]", "", (s or "")).lower() def amount_to_num(s: str): if not s: return None t = s.replace(",", "").strip() mul = 1.0 if t.endswith("亿"): mul = 1e8 t = t[:-1] elif t.endswith("万"): mul = 1e4 t = t[:-1] try: return float(t) * mul except Exception: return None def pct_to_num(s: str): if not s: return None t = s.strip().replace("%", "") try: return float(t) except Exception: return None def keyword_candidates(kw: str): out = [] if kw: out.append(kw) cleaned = re.sub(r"(类目|市场|市场数据|市场分析|行业分析|类目分析|竞品分析|趋势分析|关键词|热词|飙升词)$", "", kw or "").strip() if cleaned and cleaned not in out: out.append(cleaned) alias_map = { "书架": ["书架", "架类书架", "置物架", "收纳架"], "电脑桌": ["电脑桌", "书桌", "学习桌"], "茶桌": ["茶桌", "茶几", "家用茶台"], "床垫": ["床垫", "席梦思", "乳胶床垫"], } for k, vals in alias_map.items(): if k in (kw or ""): for v in vals: if v not in out: out.append(v) return [x for x in out if len(x) >= 2] def parse_leaf_rows(text: str): rows = [] for row in ROW_RE.findall(text): if "查看 详情" not in row: continue m = re.match(r'^(.*?) 查看 详情 ([^ ]+) (-?\d+(?:\.\d+)?)%$', row) if not m: continue prefix = m.group(1).strip() amount_raw = m.group(2).strip() yoy_raw = f"{m.group(3)}%" toks = prefix.split() if len(toks) < 4: continue one, two, three = toks[0], toks[1], toks[2] leaf = " ".join(toks[3:]) rows.append( { "row": row, "一级类目": one, "二级类目": two, "三级类目": three, "叶子类目": leaf, "销售金额": amount_raw, "销售金额同比": yoy_raw, "销售金额_num": amount_to_num(amount_raw), "销售金额同比_num": pct_to_num(yoy_raw), } ) rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True) return rows def parse_ranking_rows(text: str): rows = [] for row in ROW_RE.findall(text): if not row.endswith(" 查看"): continue m = re.match(r'^(.*?) (\d{6,20}) (\S+) (\S+) 查看$', row) if not m: continue rows.append( { "商品名称": m.group(1).strip(), "商品ID": m.group(2).strip(), "店铺": m.group(3).strip(), "品牌": m.group(4).strip(), "row": row, } ) return rows def parse_surging_rows(text: str): rows = [] for row in ROW_RE.findall(text): # 商品名 店铺 连续增长月份 近6月平均单价 销售额 m = re.match(r'^(.*?) (\S+) (\d+) ([\d,]+(?:\.\d+)?) ([\d,]+(?:\.\d+)?(?:万|亿)?)$', row) if not m: continue rows.append( { "商品": m.group(1).strip(), "店铺": m.group(2).strip(), "连续增长月份": int(m.group(3)), "近6月平均单价": m.group(4).strip(), "销售金额": m.group(5).strip(), "销售金额_num": amount_to_num(m.group(5).strip()), "row": row, } ) rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True) return rows def parse_industry(text: str): m = re.search(r'一级类目 销售额 销售额同比\s+([^\s]+)\s+([\d,]+(?:\.\d+)?(?:万|亿)?)\s+(-?\d+(?:\.\d+)?)%', text) if not m: return None return { "一级类目": m.group(1), "销售额": m.group(2), "销售额_num": amount_to_num(m.group(2)), "销售额同比": f"{m.group(3)}%", "销售额同比_num": float(m.group(3)), } leaf_rows = parse_leaf_rows(page_text.get("leaf_category", "")) ranking_rows = parse_ranking_rows(page_text.get("ranking_list", "")) surging_rows = parse_surging_rows(page_text.get("surging_rank", "")) industry = parse_industry(page_text.get("industry_distribution", "")) cand_norm = [norm(x) for x in keyword_candidates(keyword)] def row_hit(text: str): n = norm(text) return any(c in n for c in cand_norm) kw_leaf_candidates = [r for r in leaf_rows if row_hit(f"{r['三级类目']} {r['叶子类目']} {r['row']}")] kw_leaf = kw_leaf_candidates[0] if kw_leaf_candidates else None kw_rank = None if kw_leaf and kw_leaf in leaf_rows: kw_rank = leaf_rows.index(kw_leaf) + 1 same_secondary = [] if kw_leaf: same_secondary = [r for r in leaf_rows if r["二级类目"] == kw_leaf["二级类目"]] secondary_top3 = same_secondary[:3] secondary_growth_top3 = sorted(same_secondary, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or -999), reverse=True)[:3] top10_amt = sum([r["销售金额_num"] or 0 for r in leaf_rows[:10]]) if leaf_rows else 0 kw_share_of_total = None if kw_leaf and industry and industry.get("销售额_num"): if industry["销售额_num"] > 0 and kw_leaf.get("销售金额_num") is not None: kw_share_of_total = kw_leaf["销售金额_num"] / industry["销售额_num"] * 100 ranking_top20 = ranking_rows[:20] ranking_shop_counter = Counter([r["店铺"] for r in ranking_top20]) ranking_brand_counter = Counter([r["品牌"] for r in ranking_top20]) ranking_kw_hits = [r for r in ranking_top20 if row_hit(f"{r['商品名称']} {r['店铺']} {r['品牌']}")] surging_top10 = surging_rows[:10] surging_kw_hits = [r for r in surging_top10 if row_hit(f"{r['商品']} {r['店铺']}")] api_lines = [line.strip() for line in request_text.splitlines() if "/api/" in line] api_lines = api_lines[-12:] coverage = { "leaf_category": bool(page_text.get("leaf_category", "")), "ranking_list": bool(page_text.get("ranking_list", "")), "industry_distribution": bool(page_text.get("industry_distribution", "")), "surging_rank": bool(page_text.get("surging_rank", "")), } metrics = { "topic": keyword, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "scope": scope, "coverage": coverage, "industry": industry, "leaf": { "rows_count": len(leaf_rows), "keyword_row": kw_leaf, "keyword_rank": kw_rank, "keyword_share_of_total_pct": kw_share_of_total, "top10_amount_sum": top10_amt, "secondary_top3": secondary_top3, "secondary_growth_top3": secondary_growth_top3, "decline_top3": sorted(leaf_rows, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or 999))[:3], }, "ranking": { "rows_count": len(ranking_rows), "top20_unique_shop_count": len(ranking_shop_counter), "top5_shops": ranking_shop_counter.most_common(5), "top5_brands": ranking_brand_counter.most_common(5), "keyword_hits": ranking_kw_hits[:5], }, "surging": { "rows_count": len(surging_rows), "top10": surging_top10, "keyword_hits": surging_kw_hits[:5], }, "evidence": { "pages": [ {"name": "叶子类目分析", "source": "leaf_category.snapshot.txt", "captured": coverage["leaf_category"]}, {"name": "品牌店铺商品热榜", "source": "ranking_list.snapshot.txt", "captured": coverage["ranking_list"]}, {"name": "行业分布", "source": "industry_distribution.snapshot.txt", "captured": coverage["industry_distribution"]}, {"name": "飙升品榜单", "source": "surging_rank.snapshot.txt", "captured": coverage["surging_rank"]}, ], "api_lines": api_lines, }, } (workdir / "metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8") def fmt_pct(v): if v is None: return "未在当前页面取到" return f"{v:.2f}%" def fmt_ratio(v): if v is None: return "未在当前页面取到" return f"{v:.2f}%" print(f"# {keyword}市场分析简报(增强版)") print() print("## 已抓取证据") print(f"- 时间: {metrics['generated_at']}") print(f"- 范围: {metrics['scope']}") for p in metrics["evidence"]["pages"]: print(f"- 页面: {p['name']} ({'已抓取' if p['captured'] else '未抓取'})") if industry: print(f"- 行业总览: 一级类目={industry['一级类目']},销售额={industry['销售额']},同比={industry['销售额同比']}") else: print("- 行业总览: 未在当前页面取到") if kw_leaf: print(f"- 关键词命中: {kw_leaf['row']}") print(f"- 关键词排名: 第{kw_rank}/{len(leaf_rows)}(按销售金额)") else: print("- 关键词命中: 未在当前页面取到") print(f"- 请求证据(API): {len(api_lines)}条(展示最近6条)") for line in api_lines[-6:]: print(f" - {line}") print() print("## 深度分析") print("### 1) 规模与趋势") if kw_leaf: print(f"- {keyword}销售金额={kw_leaf['销售金额']},同比={kw_leaf['销售金额同比']}。") print(f"- {keyword}占一级类目销售额占比={fmt_ratio(kw_share_of_total)}。") if industry and kw_leaf.get("销售金额同比_num") is not None and industry.get("销售额同比_num") is not None: gap = kw_leaf["销售金额同比_num"] - industry["销售额同比_num"] direction = "跑赢" if gap >= 0 else "跑输" print(f"- 相对行业同比差值={gap:.2f}pct,{direction}行业大盘。") else: print("- 相对行业同比差值: 未在当前页面取到") else: print("- 关键词行未命中,暂不输出规模趋势定量结论。") print("### 2) 类目结构") if kw_leaf and same_secondary: print(f"- {keyword}位于二级类目「{kw_leaf['二级类目']}」,同二级类目共抓取{len(same_secondary)}个叶子类目。") if secondary_top3: top_desc = ";".join([f"{r['叶子类目']}({r['销售金额']}, {r['销售金额同比']})" for r in secondary_top3]) print(f"- 同二级类目销售TOP3: {top_desc}") if secondary_growth_top3: growth_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in secondary_growth_top3]) print(f"- 同二级类目增速TOP3: {growth_desc}") else: print("- 未命中关键词二级类目,结构分析不完整。") print("### 3) 竞争格局") if ranking_rows: print(f"- 热榜样本: 抓取{len(ranking_rows)}行,TOP20覆盖店铺数={metrics['ranking']['top20_unique_shop_count']}。") if metrics["ranking"]["top5_shops"]: shop_desc = ";".join([f"{k}({v})" for k, v in metrics["ranking"]["top5_shops"][:3]]) print(f"- 高频店铺(样本): {shop_desc}") if metrics["ranking"]["keyword_hits"]: hit_desc = ";".join([f"{r['商品名称']}[{r['店铺']}]" for r in metrics["ranking"]["keyword_hits"][:3]]) print(f"- 关键词相关商品(热榜命中): {hit_desc}") else: print("- 关键词相关商品(热榜命中): 未在当前页面取到") else: print("- 热榜数据未抓取到有效行。") print("### 4) 增长机会与风险") if surging_rows: print(f"- 飙升榜样本: 抓取{len(surging_rows)}行,TOP10中关键词命中={len(surging_kw_hits)}。") if surging_top10: top_surge = ";".join([f"{r['商品']}({r['销售金额']}, 连增{r['连续增长月份']}月)" for r in surging_top10[:3]]) print(f"- 当前飙升代表样本: {top_surge}") else: print("- 飙升榜数据未抓取到有效行。") if metrics["leaf"]["decline_top3"]: decline_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in metrics["leaf"]["decline_top3"][:3]]) print(f"- 同页下滑风险叶子类目TOP3: {decline_desc}") print() print("## 可执行建议") if kw_leaf: print(f"- P1: 围绕「{kw_leaf['二级类目']}->{kw_leaf['叶子类目']}」拆分价格带与主卖点,先对齐同比下滑原因。") else: print(f"- P1: 先在叶子类目页精确命中「{keyword}」后再做定量策略。") print("- P1: 以热榜TOP店铺与品牌为对标,复刻高频素材结构(标题词/卖点图/价格锚点)。") print("- P2: 用飙升榜样本补充新品池,优先测试连续增长月份>=5的商品方向。") print("- P2: 对同比显著下滑类目做止损策略(预算下调+创意替换+词包重组)。") print("- P3: 补抓价格段布局、需求趋势、关键词布局三页后再做投放预算分配。") print() print("## 风险与缺口") if not kw_leaf: print("- 关键词主行未命中,当前报告存在样本偏差风险。") if kw_leaf and kw_leaf.get("销售金额同比") == "未在当前页面取到": print("- 缺失关键词同比字段,无法做趋势判断。") print("- 当前脚本未稳定抓到销售数量/转化率字段,件数口径需在详情/字段面板补抓。") print("- 若页面筛选条件被改动且未点查询,指标可能失真(已要求SOP强制点查询)。") print() print("## 下一步") print("- 补抓: 价格段布局 + 需求趋势分析 + 关键词布局分析(同关键词、同时间窗)。") print("- 追加: 近3个月滚动对比,验证当前同比结论是否持续。") print("- 输出: 可选生成“策略执行单”(词包/价格带/竞店清单)。") print() print(f"(结构化指标文件: {workdir / 'metrics.json'})") PY