dalongxia/remote_sync/market-brief-report
2026-03-04 13:25:48 +08:00

415 lines
17 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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