199 lines
6.9 KiB
Python
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.

"""一键简报包ZIP 内含完整 Markdown 报告、结构化 JSON、要点摘录。"""
from __future__ import annotations
import io
import json
import zipfile
from pathlib import Path
from typing import Any
def _pct(x: Any) -> str:
if x is None:
return ""
try:
return f"{100 * float(x):.1f}%"
except (TypeError, ValueError):
return str(x)
def _num(x: Any) -> str:
if x is None:
return ""
if isinstance(x, (int, float)):
if isinstance(x, float) and x != int(x):
return f"{x:.2f}"
return str(int(x)) if isinstance(x, float) and x == int(x) else str(x)
return str(x)
def markdown_summary_from_brief(brief: dict[str, Any]) -> str:
"""由 ``competitor-brief`` JSON 生成便于扫读的 Markdown非 LLM"""
lines: list[str] = [
"# 竞品要点摘录(机器整理)",
"",
"> 与同批 **完整报告**、**结构化 JSON** 同源;规则汇总,定稿前请人工核对。",
"",
]
kw = brief.get("keyword") or ""
batch = brief.get("batch_label") or ""
lines.extend(
[
"## 基本信息",
"",
f"- **监测词**{kw}",
f"- **批次**{batch}",
"",
]
)
scope = brief.get("scope") or {}
if scope:
lines.extend(
[
"## 样本范围",
"",
f"- **深入 SKU 数**{_num(scope.get('merged_sku_count'))}",
f"- **评价条数(扁平)**{_num(scope.get('comment_flat_rows'))}",
f"- **结构分析用列表行数**{_num(scope.get('structure_source_rows'))}",
f"- **是否含 PC 搜索全量导出**{'' if scope.get('uses_pc_search_list_export') else ''}",
"",
]
)
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
lines.extend(
[
"## 列表侧检索规模(接口申报)",
"",
f"- **resultCount 共识值**{_num(raw.get('result_count_consensus'))}",
"",
]
)
conc = brief.get("concentration") or {}
shops = conc.get("shops_from_list") or {}
if shops.get("cr1") is not None or shops.get("top_label"):
lines.extend(
[
"## 店铺集中度(列表)",
"",
f"- **第一大店铺份额**{_pct(shops.get('cr1'))}(第一店铺:{shops.get('top_label') or ''}",
f"- **前三店铺合计份额**{_pct(shops.get('cr3'))}",
"",
]
)
dbrand = conc.get("detail_brand_among_merged") or {}
if dbrand.get("cr1") is not None or dbrand.get("top_label"):
lines.extend(
[
"## 品牌(深入样本)",
"",
f"- **第一大品牌份额(深入样本)**{_pct(dbrand.get('cr1'))}(头部:{dbrand.get('top_label') or ''}",
f"- **前三品牌合计份额**{_pct(dbrand.get('cr3'))}",
"",
]
)
pst = brief.get("price_stats") or {}
if pst.get("n"):
src = brief.get("price_stats_source") or ""
lines.extend(
[
"## 价格(展示价统计)",
"",
f"- **样本量 n**{_num(pst.get('n'))}**统计口径**{src}",
f"- **区间**{_num(pst.get('min'))} {_num(pst.get('max'))}**中位数**{_num(pst.get('median'))}",
"",
]
)
mix = brief.get("category_mix_top") or []
if mix:
lines.extend(["## 类目结构Top", ""])
for item in mix[:8]:
if isinstance(item, dict):
lines.append(
f"- {item.get('label') or ''}{_num(item.get('count'))}"
)
lines.append("")
ckw = brief.get("comment_focus_keywords") or []
if ckw:
lines.extend(["## 评价关注词Top", ""])
for item in ckw[:10]:
if isinstance(item, dict):
lines.append(
f"- **{item.get('word') or ''}**{_num(item.get('count'))}"
)
lines.append("")
usc = brief.get("usage_scenarios") or []
if usc:
lines.extend(["## 用途/场景预设词组Top", ""])
for item in usc[:8]:
if isinstance(item, dict):
lines.append(
f"- **{item.get('scenario') or ''}**{_num(item.get('count'))} 条(约 {_pct(item.get('share_of_text_units'))} 文本单元)"
)
lines.append("")
hints = brief.get("strategy_hints") or []
if hints:
lines.extend(["## 策略提示(规则)", ""])
for h in hints:
lines.append(f"- {h}")
lines.append("")
lines.extend(
[
"---",
"",
"*更细的矩阵与消费者反馈见简报包内完整分析报告;结构化字段见同包内摘要数据文件。*",
"",
]
)
return "\n".join(lines)
README_TXT = """竞品「一键简报包」说明Market-Assistant
============================================
本 ZIP 由「报告查看」页一键导出,内含:
说明文件 — 本文件
完整分析报告 — 与任务批次中的主报告文稿一致
统计图 PNG — report_assets 目录(与报告内插图同源)
结构化摘要数据 — 与「结构化摘要」接口同源,可供其它工具读取
要点摘录 — 由摘要自动整理的速读稿,便于邮件/转发前浏览
使用建议:对外发送前请核对要点摘录与完整报告中的结论;数据边界见报告第一章。
"""
def build_brief_pack_zip_bytes(run_dir: Path, brief: dict[str, Any]) -> bytes:
"""
生成 ZIP 字节流。``run_dir`` 下须存在 ``competitor_analysis.md``。
"""
run_dir = Path(run_dir).resolve()
report_path = run_dir / "competitor_analysis.md"
if not report_path.is_file():
raise FileNotFoundError("缺少已生成的分析报告文件,请先在「报告生成」中生成报告")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("01_竞品分析报告.md", report_path.read_text(encoding="utf-8"))
zf.writestr(
"02_结构化摘要.json",
json.dumps(brief, ensure_ascii=False, indent=2),
)
zf.writestr("03_要点摘录.md", markdown_summary_from_brief(brief))
zf.writestr("00_说明.txt", README_TXT)
assets = run_dir / "report_assets"
if assets.is_dir():
for fp in sorted(assets.iterdir()):
if fp.is_file():
zf.write(fp, f"report_assets/{fp.name}")
return buf.getvalue()