build_report.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. # -*- coding: utf-8 -*-
  2. """
  3. 同城对标检测 - 分析报告生成器
  4. 输入: 采集快照 CSV (每次采集累加, 一个商品一行)
  5. 输出: 04_输出/对标检测报告_<日期>.html
  6. 用法:
  7. python build_report.py # 用最新快照
  8. python build_report.py --demo # 用内置模拟数据跑演示
  9. python build_report.py --file xxx.csv # 指定快照文件
  10. """
  11. import csv
  12. import os
  13. import sys
  14. import random
  15. import statistics
  16. from datetime import date
  17. BASE = os.path.dirname(os.path.abspath(__file__))
  18. OUT_DIR = os.path.join(os.path.dirname(BASE), '04_输出')
  19. SNAP_DIR = os.path.join(os.path.dirname(BASE), '03_分析')
  20. SNAP_FIELDS = ['采集日期', '门店编码', '门店名', '门店类型', 'POI链接',
  21. '商品名', '原价', '团购价', '已售', '评分', '评价数']
  22. PRODUCTS = ['面部深层清洁', '超微小气泡', '光子嫩肤单次', '水光补水',
  23. '肩颈舒缓60min', '头皮深层清洁', '果酸焕肤', '淡斑管理',
  24. '背部疏通', '眼部护理', '小气泡3次卡', '面部抗衰套餐']
  25. def med(xs):
  26. xs = [x for x in xs if x is not None]
  27. return statistics.median(xs) if xs else 0.0
  28. def pct(x):
  29. return f'{x * 100:.0f}%'
  30. # ---------------------------------------------------------------- demo data
  31. def build_demo():
  32. """构造 10 家本店 x 4~5 家竞对 的模拟快照, 用于验证报告长什么样"""
  33. rnd = random.Random(20261029)
  34. today = date.today().isoformat()
  35. rows = []
  36. profiles = [
  37. ('C001', 168, 1.28, 4.62, 0.55),
  38. ('C002', 198, 0.92, 4.81, 1.35),
  39. ('C003', 128, 1.05, 4.75, 1.10),
  40. ('C004', 158, 1.42, 4.51, 0.42),
  41. ('C005', 178, 0.88, 4.86, 1.62),
  42. ('C006', 148, 1.12, 4.68, 0.86),
  43. ('C007', 188, 1.35, 4.58, 0.51),
  44. ('C008', 138, 0.98, 4.79, 1.24),
  45. ('C009', 168, 1.08, 4.70, 0.95),
  46. ('C010', 208, 1.25, 4.55, 0.60),
  47. ]
  48. for code, base_price, price_k, rating, strength in profiles:
  49. n_comp = rnd.choice([4, 4, 5])
  50. rows.append(dict(采集日期=today, 门店编码=code, 门店名=f'{code}门店',
  51. 门店类型='本店', POI链接=f'https://v.douyin.com/{code}/',
  52. 商品名='', 原价=0, 团购价=0, 已售=0,
  53. 评分=rating, 评价数=rnd.randint(300, 2400)))
  54. own_n = min(rnd.randint(6, 11), len(PRODUCTS))
  55. own_sold_total = int(rnd.randint(600, 3000) * strength)
  56. for nm in rnd.sample(PRODUCTS, own_n):
  57. price = round(base_price * rnd.uniform(0.6, 1.6))
  58. rows.append(dict(采集日期=today, 门店编码=code, 门店名=f'{code}门店',
  59. 门店类型='本店', POI链接=f'https://v.douyin.com/{code}/',
  60. 商品名=nm, 原价=round(price * rnd.uniform(1.6, 2.8)),
  61. 团购价=price, 已售=max(3, own_sold_total // own_n + rnd.randint(-40, 40)),
  62. 评分=rating, 评价数=0))
  63. for i in range(n_comp):
  64. cname = f'{code}商圈·竞对{i + 1}'
  65. c_rating = round(rating + rnd.uniform(-0.35, 0.35), 1)
  66. c_n = min(rnd.randint(4, 14), len(PRODUCTS))
  67. comp_pool = int(rnd.randint(500, 3200) / max(strength, 0.3))
  68. for nm in rnd.sample(PRODUCTS, c_n):
  69. price = round(base_price * rnd.uniform(0.7, 1.5) / price_k)
  70. rows.append(dict(采集日期=today, 门店编码=code, 门店名=cname,
  71. 门店类型='竞对', POI链接=f'https://v.douyin.com/x{code}{i}/',
  72. 商品名=nm, 原价=round(price * rnd.uniform(1.5, 2.6)),
  73. 团购价=price, 已售=max(2, comp_pool // c_n + rnd.randint(-30, 30)),
  74. 评分=c_rating, 评价数=rnd.randint(100, 2000)))
  75. return rows
  76. # ---------------------------------------------------------------- analysis
  77. def analyse(rows):
  78. groups = {}
  79. for r in rows:
  80. groups.setdefault(r['门店编码'], []).append(r)
  81. result = []
  82. for code, rs in groups.items():
  83. own = [r for r in rs if r['门店类型'] == '本店' and r['商品名']]
  84. comp = [r for r in rs if r['门店类型'] == '竞对' and r['商品名']]
  85. if not own:
  86. continue
  87. comp_names = {r['门店名'] for r in comp}
  88. n = len(comp_names) or 1
  89. own_price = med([r['团购价'] for r in own])
  90. comp_price = med([r['团购价'] for r in comp])
  91. own_cnt = len({r['商品名'] for r in own})
  92. cnt_by_store = {}
  93. for r in comp:
  94. cnt_by_store.setdefault(r['门店名'], set()).add(r['商品名'])
  95. comp_cnt = med([len(v) for v in cnt_by_store.values()]) if cnt_by_store else own_cnt
  96. own_sold = sum(r['已售'] for r in own)
  97. comp_sold = sum(r['已售'] for r in comp)
  98. total = own_sold + comp_sold or 1
  99. share = own_sold / total
  100. share_idx = share * (n + 1)
  101. price_idx = own_price / comp_price if comp_price else 1.0
  102. own_rating = next((r['评分'] for r in own), 0)
  103. comp_rating = med([r['评分'] for r in rs if r['门店类型'] == '竞对'])
  104. gap = own_rating - (comp_rating or own_rating)
  105. composite = (0.5 * share_idx
  106. + 0.3 * (1 + gap / 0.5 * 0.2)
  107. + 0.2 * (1 + (1 - price_idx) * 0.5))
  108. result.append(dict(
  109. 编码=code, 门店名=f'{code}门店', 竞对数=n,
  110. 本店商品数=own_cnt, 竞对商品数=comp_cnt,
  111. 本店价=own_price, 同城价=comp_price, 价格指数=price_idx,
  112. 本店销量=own_sold, 同城销量=comp_sold, 份额=share, 份额指数=share_idx,
  113. 本店评分=own_rating, 同城评分=comp_rating, 评分差=gap,
  114. 竞争力=composite,
  115. ))
  116. result.sort(key=lambda x: x['份额指数'])
  117. return result
  118. def judge(m):
  119. """检测规则 -> (标签, 等级, 归因建议)"""
  120. rules = []
  121. if m['价格指数'] > 1.2 and m['份额指数'] < 0.8:
  122. rules.append(('定价偏高', 'danger',
  123. f"价格高于同城中位 {pct(m['价格指数'] - 1)},销量份额仅为平均水平的 {m['份额指数']:.2f} 倍。"
  124. f"优先检查主图与团购标题,其次考虑下调入门款价格。"))
  125. if m['价格指数'] < 0.85 and m['份额指数'] < 0.8:
  126. rules.append(('性价比未被感知', 'warn',
  127. f"价格低于同城 {pct(1 - m['价格指数'])},但份额只有平均的 {m['份额指数']:.2f} 倍。"
  128. f"不是价格问题,是曝光或评价数量不够,检查达人铺设与评价增速。"))
  129. if m['竞对商品数'] and m['本店商品数'] < m['竞对商品数'] * 0.6:
  130. rules.append(('品项不足', 'warn',
  131. f"在售 {m['本店商品数']} 个,同城中位 {m['竞对商品数']:.0f} 个。"
  132. f"补齐引流款+利润款的组合,至少到中位水平。"))
  133. if m['评分差'] < -0.15:
  134. rules.append(('口碑落后', 'danger',
  135. f"评分低于同城中位 {abs(m['评分差']):.2f} 分。差评未处理会直接压转化,先处理近 30 天差评。"))
  136. if m['份额指数'] > 1.3 and m['价格指数'] < 1.1:
  137. rules.append(('优势店', 'good', '价格不高于同城且份额领先,把这家店的商品结构和内容打法复制到同区域其他店。'))
  138. if not rules:
  139. rules.append(('与商圈持平', 'flat', '各项指标接近同城水平,暂无明显短板。'))
  140. return rules
  141. # ---------------------------------------------------------------- render
  142. CSS = """
  143. *{box-sizing:border-box;margin:0;padding:0}
  144. body{background:#0f1115;color:#e6e8ec;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
  145. font-size:14px;line-height:1.65;padding:36px 40px 64px}
  146. .wrap{max-width:1080px;margin:0 auto}
  147. h1{font-size:22px;font-weight:600;letter-spacing:.2px}
  148. .sub{color:#8b93a1;font-size:13px;margin-top:8px}
  149. .kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:26px 0 30px}
  150. .kpi{background:#171a21;border:1px solid #252a34;border-radius:12px;padding:16px 18px}
  151. .kpi .v{font-size:24px;font-weight:600;font-variant-numeric:tabular-nums}
  152. .kpi .l{color:#8b93a1;font-size:12px;margin-top:2px}
  153. h2{font-size:15px;font-weight:600;margin:34px 0 14px;display:flex;align-items:center;gap:8px}
  154. h2 .bar{width:3px;height:14px;background:#6aa9ff;border-radius:2px}
  155. table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}
  156. th{text-align:right;color:#8b93a1;font-weight:500;font-size:12px;padding:9px 10px;
  157. border-bottom:1px solid #2a2f3a;white-space:nowrap}
  158. th:first-child,th:nth-child(2){text-align:left}
  159. td{padding:10px;border-bottom:1px solid #1d212a;text-align:right;white-space:nowrap}
  160. td:first-child,td:nth-child(2){text-align:left}
  161. tr:hover td{background:#151920}
  162. .tag{display:inline-block;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:500}
  163. .t-danger{background:rgba(255,107,107,.14);color:#ff8585}
  164. .t-warn{background:rgba(240,168,60,.14);color:#f0b45c}
  165. .t-good{background:rgba(78,201,160,.14);color:#5fd6ae}
  166. .t-flat{background:rgba(140,148,163,.14);color:#9aa3b2}
  167. .cards{display:grid;grid-template-columns:1fr;gap:10px}
  168. .card{background:#171a21;border:1px solid #252a34;border-left:3px solid #2f3745;
  169. border-radius:10px;padding:14px 18px}
  170. .card.danger{border-left-color:#ff6b6b}
  171. .card.warn{border-left-color:#f0a83c}
  172. .card.good{border-left-color:#4ec9a0}
  173. .card .h{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  174. .card .nm{font-weight:600;font-size:14px}
  175. .card .msg{color:#a8b0be;font-size:13px;margin-top:6px}
  176. .card .nums{color:#6f7787;font-size:12px;margin-top:6px;font-variant-numeric:tabular-nums}
  177. .note{color:#767e8c;font-size:12px;line-height:1.9;margin-top:14px}
  178. .note b{color:#9aa3b2;font-weight:500}
  179. .up{color:#ff8585}.down{color:#5fd6ae}
  180. """
  181. def render(metrics, meta):
  182. total_comp = sum(m['竞对数'] for m in metrics)
  183. share_avg = statistics.mean([m['份额指数'] for m in metrics]) if metrics else 0
  184. below = [m for m in metrics if m['份额指数'] < 0.8]
  185. issues = [(m, r) for m in metrics for r in judge(m) if r[1] in ('danger', 'warn')]
  186. kpis = [('试点门店', f'{len(metrics)} 家'),
  187. ('对标门店', f'{total_comp} 家'),
  188. ('份额落后门店', f'{len(below)} 家'),
  189. ('平均份额指数', f'{share_avg:.2f}')]
  190. rows_html = []
  191. for m in metrics:
  192. j = judge(m)[0]
  193. rows_html.append(f"""<tr>
  194. <td>{m['门店名']}</td><td>{m['竞对数']}</td>
  195. <td>{m['本店商品数']} / {m['竞对商品数']:.0f}</td>
  196. <td>{m['本店价']:.0f} / {m['同城价']:.0f}</td>
  197. <td class="{'up' if m['价格指数'] > 1.12 else 'down' if m['价格指数'] < 0.9 else ''}">{m['价格指数']:.2f}</td>
  198. <td>{pct(m['份额'])}</td>
  199. <td class="{'up' if m['份额指数'] < 0.8 else 'down' if m['份额指数'] > 1.2 else ''}">{m['份额指数']:.2f}</td>
  200. <td>{m['本店评分']:.1f} / {m['同城评分']:.1f}</td>
  201. <td>{m['竞争力']:.2f}</td>
  202. <td><span class="tag t-{j[1]}">{j[0]}</span></td>
  203. </tr>""")
  204. cards_html = []
  205. for m, (label, level, msg) in issues:
  206. cards_html.append(f"""<div class="card {level}">
  207. <div class="h"><span class="nm">{m['门店名']}</span><span class="tag t-{level}">{label}</span></div>
  208. <div class="msg">{msg}</div>
  209. <div class="nums">在售 {m['本店商品数']} 品(同城中位 {m['竞对商品数']:.0f}) · 价格指数 {m['价格指数']:.2f} · 份额指数 {m['份额指数']:.2f} · 评分 {m['本店评分']:.1f}</div>
  210. </div>""")
  211. if not cards_html:
  212. cards_html.append('<div class="card good"><div class="h"><span class="nm">本期未命中异常规则</span></div></div>')
  213. return f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
  214. <meta name="viewport" content="width=device-width,initial-scale=1">
  215. <title>同城对标检测报告</title><style>{CSS}</style></head><body><div class="wrap">
  216. <h1>同城对标检测报告</h1>
  217. <div class="sub">采集日期 {meta['date']} · 口径为抖音前台公开可观测指标 · 仅用于试点验证</div>
  218. <div class="kpis">{''.join(f'<div class="kpi"><div class="v">{v}</div><div class="l">{k}</div></div>' for k, v in kpis)}</div>
  219. <h2><span class="bar"></span>命中异常的 {len(issues)} 项,按优先级处理</h2>
  220. <div class="cards">{''.join(cards_html)}</div>
  221. <h2><span class="bar"></span>对标明细(按份额落后排序)</h2>
  222. <table><thead><tr>
  223. <th>门店</th><th>对标店</th><th>在售品(本/中位)</th><th>价格中位(本/同城)</th><th>价格指数</th>
  224. <th>销量份额</th><th>份额指数</th><th>评分(本/中位)</th><th>竞争力</th><th>判定</th>
  225. </tr></thead><tbody>{''.join(rows_html)}</tbody></table>
  226. <div class="note">
  227. <b>指数说明</b> 份额指数 1.00 = 与同组门店平均份额持平;价格指数 1.00 = 与同城价格中位持平;竞争力指数 = 份额 50% + 口碑 30% + 价格 20%。<br>
  228. <b>口径局限</b> 销量来自前台「已售」累计值,含历史,单次采集不能代表当日销量;要算真实日增需连续两次采集取差值。曝光、转化率、核销率、退款率属商家私有数据,外部采集拿不到,本报告不涉及。<br>
  229. <b>试点性质</b> {'本页数据为模拟样例,用于确认字段与判定规则是否可用,不代表真实经营结论。' if meta['demo'] else '数据由实际采集生成。'}
  230. </div>
  231. </div></body></html>"""
  232. def main():
  233. args = sys.argv[1:]
  234. demo = '--demo' in args
  235. rows, src = [], ''
  236. if demo:
  237. rows = build_demo()
  238. else:
  239. path = None
  240. if '--file' in args:
  241. path = args[args.index('--file') + 1]
  242. else:
  243. files = sorted(f for f in os.listdir(SNAP_DIR) if f.endswith('.csv')) if os.path.isdir(SNAP_DIR) else []
  244. if files:
  245. path = os.path.join(SNAP_DIR, files[-1])
  246. if not path or not os.path.exists(path):
  247. print('未找到采集快照 CSV。可先用 --demo 查看报告形态。')
  248. sys.exit(1)
  249. src = path
  250. with open(path, encoding='utf-8-sig') as f:
  251. for r in csv.DictReader(f):
  252. r['团购价'] = float(r.get('团购价') or 0)
  253. r['原价'] = float(r.get('原价') or 0)
  254. r['已售'] = float(r.get('已售') or 0)
  255. r['评分'] = float(r.get('评分') or 0)
  256. rows.append(r)
  257. metrics = analyse(rows)
  258. if not metrics:
  259. print('快照里没有可分析的数据,检查「门店类型」是否填了「本店 / 竞对」。')
  260. sys.exit(1)
  261. meta = dict(date=rows[0]['采集日期'] if rows else date.today().isoformat(), demo=demo)
  262. os.makedirs(OUT_DIR, exist_ok=True)
  263. suffix = '示例' if demo else meta['date'].replace('-', '')
  264. out = os.path.join(OUT_DIR, f'对标检测报告_{suffix}.html')
  265. with open(out, 'w', encoding='utf-8') as f:
  266. f.write(render(metrics, meta))
  267. print(f'报告已生成: {out}')
  268. print(f'分析门店 {len(metrics)} 家, 竞对 {sum(m["竞对数"] for m in metrics)} 家')
  269. for m in metrics[:5]:
  270. print(f" {m['门店名']} 份额指数 {m['份额指数']:.2f} 价格指数 {m['价格指数']:.2f} {judge(m)[0][0]}")
  271. if src:
  272. print(f'数据源: {src}')
  273. if __name__ == '__main__':
  274. main()