1. 项目概览
用途:抓取 V2EX 论坛指定节点下的帖子及回复,存为本地 JSON 文件,再调用 LLM 提炼创意、痛点、独立开发者机会、趋势洞察。
运行方式:命令行工具,可 Docker 部署。两个核心脚本 + 一个配置文件 + 本地数据目录。
语言选择:不限。推荐 Python(urllib 无依赖),也可用 TypeScript/Go/Rust。Docker 镜像任选。
2. 项目结构
.
├── config.json # 节点列表 + 爬取参数
├── crawler.py # 爬虫:抓取帖子 → 本地 JSON
├── analyzer.py # 分析:读 JSON → 调 LLM → 输出报告
├── data/
│ ├── state.json # 运行状态(时间戳)
│ ├── analysis.md # 全量分析报告(analyzer.py 全量输出)
│ ├── analysis_today.md # 今日增量报告(analyzer.py --today 输出)
│ ├── .cache/ # 分析模块的中间缓存(LLM 返回结果)
│ │ └── topic_lists/ # 爬虫模块的帖子列表日缓存
│ ├── programmer/ # 每个节点一个子目录
│ │ ├── 1229217.json # 每个帖子一个 JSON 文件
│ │ └── ...
│ └── python/
│ └── ...
└── README.md关键约定:
data/下按节点名建子目录,每个帖子存一个{id}.jsonstate.json记录last_crawl和last_analysis两个 unix 时间戳缓存全放在
data/.cache/下,按run_id区分不同批次
3. 配置文件
3.1 config.json 结构
{
"nodes": ["programmer", "python", ...], // 要爬取的节点名列表
"pages_per_node": 5, // 每个节点爬多少页(每页 ~10 条)
"request_delay": 1.2, // API 请求间隔(秒),控制频率
"max_retries": 3 // 请求失败重试次数
}设计要点:
节点名对应 V2EX API 中的
node_name参数延迟和重试在
crawler.py中使用,analyzer.py不直接读所有硬编码参数都抽象到配置文件
3.2 环境变量(分析模块用)
设计要点:
API Key 不写入配置文件,不硬编码,只走环境变量
兼容任何 OpenAI 协议的 API(DeepSeek、OpenRouter 等)
4. 数据结构
4.1 帖子 JSON(爬虫输出/分析输入)
{
"id": 1229217, // 帖子 ID(整数)
"title": "...", // 标题
"content": "...", // 正文(可能为空字符串)
"member": "用户名", // 发帖人
"created": 1784774167, // 发帖时间(unix timestamp)
"replies_count": 5, // 回复数(整数)
"node": "programmer", // 所属节点
"url": "https://www.v2ex.com/t/1229217", // 帖子链接
"reply_list": [
{
"id": 123456, // 回复 ID
"member": "回复人",
"content": "...", // 回复内容(可能空)
"created": 1784775000 // 回复时间
}
]
}关键约束:
content和reply.content都可能是空字符串(V2EX API 的实际行为)reply_list最多存 10 条回复(分析时只读前 10 条,避免 token 爆炸)replies_count是原始回复总数,不等于len(reply_list)created是 unix timestamp,用于增量过滤和排序
4.2 state.json 结构
{
"last_crawl": 1785299416, // 最后一次爬取时间戳
"last_analysis": 1784793852 // 最后一次分析时间戳
}字段说明:
last_crawl:crawler.py --today用,过滤此时间之后的帖子last_analysis:analyzer.py --today用,标记增量范围两者独立,允许只爬不分析或只分析不爬
4.3 缓存结构
爬虫帖子列表缓存:data/.cache/topic_lists/{node}_{YYYY-MM-DD}.json
存
fetch_topics的完整返回(帖子列表数组)同一天内重复运行直接读缓存,不重复请求 API
校验条件:缓存条数 ≥
pages_per_node * 5(确保覆盖足够)
分析中间缓存:data/.cache/{run_id}_{key}.json
run_id=md5(帖子ID列表拼接).hexdigest()[:12],确保同一批帖子复用同一缓存key格式:b{批次号}_{类别名},如b0_ideas、b3_pain存
{"key": "...", "result": "LLM 原始返回文本"}分析完成后清除(
_clean_cache)
5. 爬虫模块 (crawler.py)
5.1 API 层
函数:fetch_json(path, params="", retries=MAX_RETRIES)
输入:
path:API 路径,如"topics/show.json"、"replies/show.json"、"nodes/all.json"params:查询参数字符串,如"node_name=python&p=1"
输出:解析后的 JSON(list 或 dict),失败返回 []
行为:
拼接 URL:
https://www.v2ex.com/api/{path}?{params}设置 User-Agent:
V2EX-Crawler/1.0(V2EX v1 API 无需认证)超时 30 秒
指数退避重试:
delay * 2^attempt,上限 60 秒HTTP 404/403 直接返回
[],不重试(帖子被删或权限不足)其他错误重试到
max_retries次
注意:用标准库 urllib.request,不引入第三方依赖(requests 等)。
5.2 帖子列表抓取
函数:fetch_topics(node, pages=3)
逻辑:
先检查今日缓存
data/.cache/topic_lists/{node}_{today}.json存在且条数 ≥
pages * 5:直接返回缓存
否则逐页请求
topics/show.json?node_name={node}&p={p}用
set去重(API 分页可能返回重复数据)每页间
sleep(DELAY)(1.2 秒)写入缓存文件
返回:去重后的帖子列表(每帖含 id/title/content/member/created/replies/node/url)
5.3 回复抓取
函数:fetch_replies(topic_id)
调用:replies/show.json?topic_id={topic_id}
返回:回复列表(每回复含 id/member/content/created)
注意:调用后 sleep(DELAY),控制请求频率
5.4 帖子保存
函数:save_topic(out_dir, topic, replies)
输入:
out_dir:节点子目录,如data/programmer/topic:帖子元数据(来自fetch_topics)replies:回复列表(来自fetch_replies)
输出:{out_dir}/{topic_id}.json,按 4.1 结构序列化
注意:reply_list 只存前 10 条回复(控制文件大小和后续分析 token)
5.5 主入口
函数:crawl(node, pages=3, output_dir=None, since=None)
参数:
node:节点名pages:爬取页数output_dir:默认data/{node}since:unix timestamp,增量模式时过滤此时间之后的帖子
流程:
创建输出目录
调用
fetch_topics获取帖子列表如果
since传入,过滤created >= since的帖子遍历帖子:
本地已有对应 JSON 文件 → 跳过(不重复请求)
否则调用
fetch_replies→save_topic
打印进度:
[i/总数] id - 标题返回新增帖子数
5.6 CLI 入口
python3 crawler.py # 全量爬取
python3 crawler.py --today # 增量爬取(只爬上次之后的)
python3 crawler.py list # 列出所有可用节点
python3 crawler.py list python # 按关键词过滤节点--today 模式逻辑:
读
state.json中的last_crawl时间戳传入
crawl(..., since=last_crawl)爬取完成后更新
state.json的last_crawl为当前时间
list 模式:
调用
nodes/all.json获取所有节点按
parent_node_name分组打印支持关键词过滤(匹配
name或title)
6. 分析模块 (analyzer.py)
6.1 核心分类体系
四个分析维度,每个维度独立分析 + 独立合并:
设计要点:
四个维度互不干扰,每个类别独立一个 prompt 调用
单次 prompt 只分析一个类别,不混合
每个类别要求「尽可能多地提炼,不要限制条数」
6.2 Prompt 设计
单批次分析 prompt:
分析以下 V2EX 帖子,只提炼「{title}」类信息。
定义:{desc}
要求:
- 只输出这一类,不要输出其他类别
- 不要限制条数,尽可能多地提炼有价值的信息
- 每条附上来源帖子标题
- 用中文回答
---(第 {batch_idx+1}/{total_batches} 批)
{帖子文本}帖子文本格式:
## {标题}
作者: {用户名} | 回复数: {N}
{正文前 500 字符}
- {回复人}: {回复内容前 200 字符}
- ...
---每个帖子正文截断 500 字符,每帖最多 10 条回复,每条回复截断 200 字符
目的:控制 token,避免超长帖子炸上下文
合并 prompt(全量模式):
以下是多批次的 V2EX 帖子分析结果,请合并去重,输出一份最终报告。
要求:
- 去除重复条目,保留最有代表性的描述
- 不要限制条数,尽可能保留所有有价值的信息
- 按价值从高到低排列
- 用中文回答合并 prompt(增量模式):
同上,但标题注明这是今日新增分析6.3 批量分析 + 缓存
函数:analyze(topics, incremental=False)
输入:
topics:帖子列表(从load_topics读取的完整 JSON 对象)incremental:是否增量模式(影响合并 prompt)
参数:
BATCH_SIZE = 15:每批 15 个帖子MERGE_SIZE = 3:合并时每 3 个结果合并一次
流程:
计算 run_id:
run_id = md5(所有帖子 ID 拼接).hexdigest()[:12]同一批帖子不同次运行复用同一缓存
分批:
[topics[i:i+15] for i in range(0, len, 15)]每批并发分析 4 个类别:
对每个类别
(key, title, desc):先查缓存
data/.cache/{run_id}_b{batch_idx}_{key}.json缓存命中 → 直接用
缓存未命中 → 调 LLM → 写缓存
每批返回
[(类别标题, 分析结果), ...]共 4 项
按类别独立合并:
每个类别自己的 N 个结果,层级合并:
N ≤ MERGE_SIZE(3) → 一次合并
N > 3 → 每 3 个一组合并,递归直到只剩 1 个
合并时调用 LLM(timeout=300,比分析更长)
增量模式用
MERGE_PROMPT_INCREMENTAL,全量用MERGE_PROMPT
清理缓存:分析完成后删除该
run_id的所有缓存文件
返回:{ "好的创意/产品点子": "文本", "用户痛点": "文本", ... } 字典
6.4 API 调用
函数:call_api(prompt, timeout=120, retries=3)
实现:
POST
{BASE_URL}/chat/completionsBody:
{"model": MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, "max_tokens": 4096}Header:
Authorization: Bearer {API_KEY}、Content-Type: application/json超时 120 秒(单次分析),合并调用传 300 秒
失败重试:指数退避,
5 * 2^attempt,上限 60 秒
6.5 帖子加载
函数:load_topics(data_dir, since=None)
输入:
data_dir:节点数据目录,如data/programmer/since:unix timestamp,只加载此时间之后的帖子
实现:
遍历目录下所有
*.json文件按文件名排序(确保稳定性)
since传入时过滤created < since的帖子返回完整帖子对象列表
6.6 主入口
函数:main(nodes=None, incremental=False)
流程:
自动发现节点:如果
nodes为 None,扫描data/下所有子目录增量模式时读
state.json,取last_analysis或last_crawl作since遍历每个节点目录,调用
load_topics加载帖子给每个帖子加
source_node字段(标记来源节点)合并所有节点帖子,调
analyze写分析报告到
data/analysis.md(全量)或data/analysis_today.md(增量)更新
state.json的last_analysis控制台打印每个维度的完整结果
报告格式:
# V2EX 多节点分析
节点: programmer, python, ...
基于 1234 个帖子自动生成
## 好的创意/产品点子
{LLM 合并后的文本}
## 用户痛点
{LLM 合并后的文本}
...6.7 CLI 入口
python3 analyzer.py # 全量分析
python3 analyzer.py --today # 增量分析(只分析新帖)环境变量检查:
OPENAI_API_KEY为空时直接exit(1),提示设置方法提示中列出三个环境变量和可选值
7. 每日增量工作流
7.1 设计理念
每天上班前跑一次 python3 crawler.py --today && python3 analyzer.py --today
首次运行:
crawler.py --today:没有last_crawl时间戳,since=None,全量爬取analyzer.py --today:没有last_analysis时间戳,since=None,全量分析运行完各自记录时间戳
后续运行:
爬虫只请求
created >= last_crawl的新帖子分析只加载
created >= last_analysis的新帖子本地已存在的 JSON 文件自动跳过
7.2 状态流转
首次运行:
state.json 不存在 → 全量爬取 → 写 last_crawl
state.json 不存在 → 全量分析 → 写 last_analysis
后续运行:
读 last_crawl → 增量爬取(since=last_crawl)→ 更新 last_crawl
读 last_analysis → 增量分析(since=last_analysis)→ 更新 last_analysis边界情况:
只有
last_crawl没有last_analysis:分析时 fallback 到last_crawl两个时间戳都没有:全量模式,打印「首次运行」提示
增量模式下没有新帖子:打印「没有新增帖子,无需分析」并退出
8. 关键边界情况 & 健壮性
8.1 API 限流
V2EX v1 API 限制约 600 次/小时/IP
request_delay默认 1.2 秒,确保不超限指数退避重试,404/403 不重试直接跳过
8.2 帖子去重
fetch_topics内部用set去重(API 分页可能返回重复)crawl主循环检查本地文件是否存在,已存在跳过(断点续传)
8.3 大帖子分析
每批 15 个帖子,超过 15 个自动分多批
正文截断 500 字符,回复截断 200 字符,每帖最多 10 条回复
多批次合并使用层级合并,避免单次 prompt 过大
8.4 缓存策略
爬虫缓存:帖子列表按天缓存,同一天不重复请求
分析缓存:按
run_id + key缓存单个类别的 LLM 结果,重跑不重复调 API分析完成后清理缓存,避免磁盘堆积
8.5 错误处理
爬虫 API 失败返回
[],不抛异常,不中断流程分析 API 重试 3 次后仍失败则
raise,终止分析环境变量缺失时
exit(1),打印配置说明
8.6 目录创建
所有
os.makedirs带exist_ok=True输出目录不存在时自动创建,不要求预先存在
9. Docker 部署
9.1 镜像设计
FROM python:3.12-slim
WORKDIR /app
COPY crawler.py analyzer.py config.json ./
RUN mkdir -p data
ENV OPENAI_API_KEY="" \
OPENAI_BASE_URL="https://api.openai.com/v1" \
ANALYZE_MODEL="gpt-4o-mini"运行:
# 爬取
docker run --rm -v $(pwd)/data:/app/data -v $(pwd)/config.json:/app/config.json \
v2ex-crawler python3 crawler.py --today
# 分析
docker run --rm \
-v $(pwd)/data:/app/data \
-v $(pwd)/config.json:/app/config.json \
-e OPENAI_API_KEY=sk-xxx \
-e OPENAI_BASE_URL=https://api.deepseek.com/v1 \
-e ANALYZE_MODEL=deepseek-chat \
v2ex-crawler python3 analyzer.py --today要点:
data/目录挂载出来,保证数据持久化config.json单独挂载,方便修改节点列表API Key 通过
-e传入,不写死两个命令分开跑,互不依赖
9.2 可选:cron 定时
# 每天 9 点自动跑
0 9 * * * cd /path/to/project && python3 crawler.py --today && python3 analyzer.py --todayDocker 版用宿主 cron 或 docker-compose 的 ofelia 调度。