这篇能做出什么
跑完这套流程,你会得到一个可以反复执行的竞品分析流水线,一次运行产出四样东西:
1. 一份结构化事实表(CSV + Markdown 表格),横轴是竞品,纵轴是定位、目标用户、核心功能、计费方式、免费额度、宣称差异点。
2. 一张功能覆盖对比图(PNG),把"哪家提到了某个能力"变成可比的矩阵,一眼看出谁缺什么。
3. 一份 800 字以内的分析摘要,包含结论、差异点和机会点,每条结论后面标注依据来自哪家的哪个字段。
4. 可追溯的原始素材:抓下来的公开页面正文和每条结论的原文片段,方便你复核 AI 有没有编。
"5 分钟"指的是第二次以后的运行时间——第一次因为要装环境、挑页面、调字段,大概要半小时到一小时。跑顺之后,改一行 CSV 加个竞品,重跑脚本,几分钟出结果。
需要先说清楚边界:这套流程只处理公开可访问的页面(官网、定价页、帮助中心、更新日志、公开文档)。它不替代真实用户访谈,也不能把 AI 给的判断当客观事实。所有涉及价格、版本、额度的数字,一律以官方页面当前展示为准,报告里要写上采集日期。
前置条件清单
- 一台能装 Python 的机器(本地或云主机都行,Python 3 的较新版本,具体以官方文档为准)
- 一个可调用的大模型接口。可以是云厂商的 API,也可以本地跑 Ollama 之类的服务;只要提供 OpenAI 兼容的 HTTP 接口就能接。模型名、计费方式以官方文档为准
- 3 到 5 个竞品的公开页面清单。少于 3 家看不出差异,多于 5 家第一次做会累
- 基础的命令行操作能力:会
cd、会跑pip install、会看报错
关于抓取的合规底线,写在最前面:
- 只抓公开页面,不碰需要登录、需要付费、有验证码的内容,不绕过任何访问控制
- 动手前看一眼目标站点的
robots.txt和服务条款,对方明确禁止采集就换来源(比如公开的更新日志、应用商店页面描述、公开文档) - 请求之间加延时,别把人家站点打崩。每秒几十个请求的行为既没礼貌也容易被封
分步骤
第 1 步:建目录、装依赖
```bash
mkdir -p ca/{raw,clean,out}
cd ca
python -m venv .venv
macOS / Linux
source .venv/bin/activate
Windows PowerShell
.venv\Scripts\Activate.ps1
pip install requests beautifulsoup4 pandas tabulate matplotlib python-dotenv openai
```
tabulate 是 pandas.to_markdown() 的依赖,不装会报错。如果你想抓 JS 渲染的页面,再额外装 Playwright:
```bash
pip install playwright
playwright install chromium
```
第 2 步:把竞品清单写成 CSV
新建 targets.csv,一行一个来源。同一家可以有多行(官网首页 + 定价页 + 文档页):
```csv
brand,url,note
产品A,https://example.com/,官网首页
产品A,https://example.com/pricing,定价页
产品B,https://example.org/,官网首页
产品B,https://example.org/docs,文档首页
产品C,https://example.net/,官网首页
```
用 CSV 而不是写死在代码里,是为了后面加竞品时只改数据不动逻辑。
第 3 步:抓取网页正文
新建 fetch.py:
```python
fetch.py
import csv, json, time, pathlib, urllib.parse
import requests
from bs4 import BeautifulSoup
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; ResearchBot/1.0; +contact@example.com)"}
OUT = pathlib.Path("raw")
OUT.mkdir(exist_ok=True)
def clean_html(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
去掉对正文没帮助、且噪声很大的标签
for tag in soup(["script", "style", "noscript", "svg", "nav", "footer", "form"]):
tag.decompose()
text = soup.get_text("\n", strip=True)
lines = [line.strip() for line in text.splitlines() if line.strip()]
return "\n".join(lines)
def fetch(url: str) -> str:
resp = requests.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding # 处理部分站点的编码声明不准
return clean_html(resp.text)
rows = list(csv.DictReader(open("targets.csv", encoding="utf-8")))
for i, row in enumerate(rows):
url = row["url"].strip()
try:
text = fetch(url)
host = urllib.parse.urlparse(url).netloc.replace(".", "_")
name = f'{i:02d}_{row["brand"]}_{host}.txt'
(OUT / name).write_text(text, encoding="utf-8")
print(f"OK {name} {len(text)} 字符")
except Exception as exc:
print(f"FAIL {url} {exc}")
time.sleep(2) # 限速,别打崩对方站点
```
跑一下:
```bash
python fetch.py
```
如果某个页面抓回来只有几百字符,多半是 JS 渲染的空壳,看后面的排错部分。
第 4 步:截断与打包素材
大模型有上下文上限,整篇网页塞进去既贵又容易被截断。这里做一层朴素处理:每份素材取前若干字符,保留开头(通常是标题、价值主张、功能概览)。
```python
prep.py
import json, pathlib
MAX_CHARS = 12000
snippets = []
for path in sorted(pathlib.Path("raw").glob("*.txt")):
text = path.read_text(encoding="utf-8")
snippets.append({"source": path.stem, "chars": len(text), "text": text[:MAX_CHARS]})
pathlib.Path("clean").mkdir(exist_ok=True)
pathlib.Path("clean/snippets.json").write_text(
json.dumps(snippets, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"打包 {len(snippets)} 份素材")
```
```bash
python prep.py
```
第 5 步:让模型按固定字段抽取
这一步是关键。不要让模型直接写分析报告,先让它把网页变成结构化字段。字段固定了,后面才能做表、做图、做对比。
先建 .env 文件(记得加进 .gitignore,别把密钥提交上去):
```bash
LLM_API_KEY=你的密钥
LLM_BASE_URL=https://your-endpoint/v1
LLM_MODEL=你的模型名
```
```python
extract.py
import json, os, pathlib
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
kwargs = {}
if os.environ.get("LLM_BASE_URL"):
kwargs["base_url"] = os.environ["LLM_BASE_URL"]
client = OpenAI(api_key=os.environ["LLM_API_KEY"], **kwargs)
MODEL = os.environ["LLM_MODEL"]
SYSTEM_PROMPT = """你在做竞品资料抽取。只依据给定文本作答,不要脑补,不要用外部知识补充。
按下面的 JSON 结构输出,字段缺失就填 "unknown":
{
"brand": "品牌或产品名",
"positioning": "一句话定位",
"target_user": "目标用户描述",
"core_features": ["核心功能,最多 5 条"],
"pricing_model": "计费方式,如订阅制/按量计费/免费加增值,看不到写 unknown",
"free_tier": "免费额度或试用策略,看不到写 unknown",
"differentiators": ["文本中自称的差异点,最多 3 条"],
"evidence": ["支撑上述判断的原文片段,最多 3 条,每条不超过 40 字"]
}
只输出 JSON,不要输出解释、不要输出 Markdown 代码块。"""
def safe_json(text: str) -> dict:
"""兼容部分接口不支持 json_object 的情况。"""
text = text.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
start, end = text.find("{"), text.rfind("}")
if start == -1 or end == -1:
raise ValueError(f"返回内容里找不到 JSON: {text[:200]}")
return json.loads(text[start:end + 1])
def extract(snippet: dict) -> dict:
resp = client.chat.completions.create(
model=MODEL,
temperature=0,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"来源:{snippet['source']}\n\n正文:\n{snippet['text']}"},
],
)
data = safe_json(resp.choices[0].message.content)
data["_source"] = snippet["source"]
return data
snippets = json.loads(pathlib.Path("clean/snippets.json").read_text(encoding="utf-8"))
results = []
for s in snippets:
try:
results.append(extract(s))
print(f"OK {s['source']}")
except Exception as exc:
print(f"FAIL {s['source']} {exc}")
pathlib.Path("out").mkdir(exist_ok=True)
pathlib.Path("out/facts.json").write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"共 {len(results)} 条记录")
```
如果你的接口支持 response_format={"type": "json_object"},加上它能明显降低解析失败率;不支持就删掉这个参数,safe_json 会兜底。
```bash
python extract.py
```
第 6 步:汇总成对比表
```python
table.py
import json, pathlib
import pandas as pd
facts = json.loads(pathlib.Path("out/facts.json").read_text(encoding="utf-8"))
rows = []
for f in facts:
rows.append({
"来源": f.get("_source", ""),
"品牌": f.get("brand", "unknown"),
"定位": f.get("positioning", "unknown"),
"目标用户": f.get("target_user", "unknown"),
"核心功能": "; ".join(f.get("core_features") or []),
"计费方式": f.get("pricing_model", "unknown"),
"免费额度": f.get("free_tier", "unknown"),
"差异点": "; ".join(f.get("differentiators") or []),
})
df = pd.DataFrame(rows)
df.to_csv("out/compare.csv", index=False, encoding="utf-8-sig") # Excel 打开不乱码
pathlib.Path("out/compare.md").write_text(df.to_markdown(index=False), encoding="utf-8")
print(df.to_markdown(index=False))
```
```bash
python table.py
```
到这里,事实表已经能用了。同一家有多行来源时,你会看到多条记录——这是有意的,方便你交叉核对;需要合并时在 table.py 里按品牌 groupby 一下即可。
第 7 步:画功能覆盖图
不要直接让模型"打个分",那属于主观判断。更稳的做法是:先用规则把功能关键词二值化,再画矩阵。
```python
chart.py
import json, pathlib
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
中文字体:按系统里实际装了的字体名来填,装不上就换成英文标签
plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "Source Han Sans SC", "SimHei", "Arial Unicode MS"]
plt.rcParams["axes.unicode_minus"] = False
这些词条按你的行业自己改
KEYWORDS = ["导出", "协作", "API", "权限", "模板", "多语言", "离线"]
facts = json.loads(pathlib.Path("out/facts.json").read_text(encoding="utf-8"))
rows = []
for f in facts:
blob = " ".join((f.get("core_features") or []) + (f.get("differentiators") or []))
blob += " " + (f.get("positioning") or "")
rows.append({"来源": f.get("_source", ""), **{k: int(k in blob) for k in KEYWORDS}})
df = pd.DataFrame(rows).set_index("来源")
ax = df.plot(kind="bar", figsize=(12, 5), width=0.8)
ax.set_ylabel("公开页面是否提及(1 = 提到)")
ax.set_title("功能覆盖对比:依据公开页面文本自动提取")
plt.xticks(rotation=30, ha="right")
plt.tight_layout()
pathlib.Path("out").mkdir(exist_ok=True)
plt.savefig("out/feature_matrix.png", dpi=150)
print("已生成 out/feature_matrix.png")
```
```bash
python chart.py
```
注意图注要写清"依据公开页面文本自动提取",避免读者误以为这是实测结论。
第 8 步:生成分析摘要
最后一步才是让模型写报告,输入是上一步的事实表。
```python
report.py
import json, os, pathlib
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
kwargs = {}
if os.environ.get("LLM_BASE_URL"):
kwargs["base_url"] = os.environ["LLM_BASE_URL"]
client = OpenAI(api_key=os.environ["LLM_API_KEY"], **kwargs)
MODEL = os.environ["LLM_MODEL"]
table = pathlib.Path("out/compare.md").read_text(encoding="utf-8")
PROMPT = """下面是从各竞品公开页面抽取的结构化事实表。请写一份中文竞品分析摘要。
要求:
1. 结构依次为:一句话结论 / 定位差异 / 计费与免费策略对比 / 功能覆盖差异 / 三条可验证的机会点。
2. 每条结论后面用括号注明依据,格式如(产品A·定价页·计费方式)。
3. 事实表中标注为 unknown 的,写"公开页面未说明",不要推测。
4. 不要复述表格,要给出对比后的判断。
5. 全文不超过 800 字,最后一行写明"以上信息来自公开页面自动抽取,价格与版本以官方页面为准"。
事实表:
{table}
"""
resp = client.chat.completions.create(
model=MODEL,
temperature=0.3,
messages=[{"role": "user", "content": PROMPT}],
)
body = resp.choices[0].message.content
pathlib.Path("out/report.md").write_text(body, encoding="utf-8")
print(body)
```
```bash
python report.py
```
第 9 步:一键重跑
把流程串起来,新建 run.sh:
```bash
#!/usr/bin/env bash
set -euo pipefail
python fetch.py
python prep.py
python extract.py
python table.py
python chart.py
python report.py
echo "完成:out/compare.csv, out/feature_matrix.png, out/report.md"
```
```bash
chmod +x run.sh
./run.sh
```
以后加竞品只需要改 targets.csv,重跑一次。
常见坑与排错
抓回来是空壳或只有导航文字。 页面正文由 JS 渲染,requests 拿不到。用 Playwright 打开再取 page.content(),或者直接换一个静态来源(帮助中心、文档站、更新日志通常更容易抓)。
模型编造原文里没有的价格。 三个措施一起上:temperature 设 0、prompt 里明确"看不到写 unknown"、强制输出 evidence 字段并要求是原文片段。拿到结果后随手抽两三条,回原始 txt 里搜一下能不能对上。
返回内容不是合法 JSON。 先看接口是否支持 response_format;不支持时保留 safe_json 兜底。另外,极长的输入容易让模型中途截断输出,把 MAX_CHARS 调小试试。
中文在图表里变成方块。 系统里没装中文字体,或者字体名写错了。用 fc-list :lang=zh(Linux)看一下实际可用的字体名,填进 font.sans-serif;实在不行就把图表标签改成英文。
CSV 用 Excel 打开乱码。 写入时用 encoding="utf-8-sig",上面的代码已经这么做了。
抓取被封或收到警告。 立刻停手,检查 robots.txt 与站点条款,拉长 time.sleep 间隔,减少抓取页数。合规问题不值得为一份报告冒险。
价格数字过期。 抓取时间戳要落到报告里。任何时候都要提醒读者:以官方页面当前展示为准。
把 AI 的主观判断当成结论。 摘要里的每句话都应有字段依据。如果某条结论后面标不出依据,就删掉它。
下一步建议
- 做变化追踪:把每次的
facts.json带日期存档,下次运行时对比,自动提示"产品B 的免费额度字段变了"。这比重新读一遍报告有用得多。 - 扩来源:更新日志能看出迭代节奏,招聘页面能看出投入方向,公开的应用商店评论能反映用户真实抱怨。注意各平台的采集规则。
- 把字段再细化:比如把"计费方式"拆成计费维度、起付门槛、超额规则三个字段,表格会更好用。
- 接进日常工具:把
out/report.md推到团队群或邮件里,改成每周定时跑一次,竞品分析就从"项目"变成了"背景信息"。 - 加一条人工复核清单:价格、免费额度、功能声称这三类字段必须人工确认,其余可以抽查。自动化省的是整理时间,判断还得人来下。
