415 lines
17 KiB
Bash
Executable File
415 lines
17 KiB
Bash
Executable File
#!/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
|