ReplyHey 给自己的定位是 Autopilot,让用户通过自动回复从 Reddit 寻找潜在客户。真正动手做这个主题之前,要先把它从营销叙事翻译成工程问题:监控哪些 subreddit、按什么标准筛选帖子、怎样生成不惹人厌的回复、如何控制频率,以及怎样在 Reddit 的 API 限制和社区规则下安全运行。
这些工作可以拆成一个最小可复现系统:一个进程负责扫描,一个队列负责存储,一个审核步骤负责把关,一个 API 调用负责真正提交回复。本文会按这条主线搭建一个 Python 示例,从 Reddit 应用注册开始,到扫描、过滤、生稿、审核、提交和排查结束。文章假定你已经掌握 Python 基础,了解 HTTP 和 OAuth 的基本概念。
先从整体设计开始。
1. 先理解 ReplyHey 这类工具的技术本质
1.1 自动回复不是“自动群发”,而是“自动筛选 + 人工确认”
“Autopilot”这个词容易让人产生误解:是不是写一个脚本,看到有人问“有没有推荐工具”,就立刻冲上去回复自己的产品?如果这样做,技术难度确实很低,但它不是可长期运行的系统,因为 Reddit 用户对广告非常敏感,subreddit 版务也会封禁频繁推广的账号。真正值得实现的是“自动筛选 + 草稿生成 + 人工确认”这条流水线。
整个系统的本质可以概括为:
- 读取一个或多个 subreddit 的新帖和评论流。
- 用规则判断该讨论是否存在真实需求。
- 如果存在,生成一段回复草稿。
- 草稿进入审核队列,由运营人员决定是否回复、修改后回复或放弃。
- 只有审核通过的草稿才会通过 Reddit API 真正提交。
- 所有扫描、审核、提交动作都写入日志和数据库。
这样设计的好处是:自动化处理了重复劳动,但最终决定权仍然留在人手里,出问题时可以回溯是哪一份草稿、哪次提交造成的。
1.2 系统边界:范围、频率、状态和审计
技术实现前要先定义边界,否则写着写着就会失控。
范围:不能扫描整个 Reddit,要限定在跟产品场景相关的 subreddit。产品是发票工具,就只看 r/freelance、r/smallbusiness、r/invoicing 等;产品是开发者工具,就只看 r/webdev、r/selfhosted 等。
频率:Reddit API 有速率限制,程序内部也要设置自己的调度间隔。代码里宁可保守,也不要把请求打满。
状态:每条帖子在数据库里必须有一条完整状态记录,常见状态包括 pending、approved、rejected、replied、skipped、failed。有了状态,才能做幂等处理,避免重复回复。
审计:每条回复都要保留原始帖子标题、链接、生成时间、回复内容、操作人。这不仅是工程要求,也是平台规则和口碑管理的需要。
这三层约束会直接影响后面的数据库设计和主循环写法。
1.3 平台规则对架构设计的影响
Reddit 对自动化请求有明确要求,虽然是社区平台,API 规则更像一家严肃的 SaaS 服务。基础要求包括:
- 使用 OAuth2 认证,不能再用匿名接口做业务操作。
- User-Agent 必须能识别调用方身份。
- 对单个端点的请求需要控制频率,遇到 429 必须退避。
- 自动回复如果触发反垃圾机制,账号会被限流或封禁。
- 不同 subreddit 有不同的推广规则,代码需要支持按 subreddit 配置黑名单。
这些要求不是安全文档里的空话,它们会直接影响架构。比如“不同 subreddit 有不同规则”,意味着过滤层不能只做全局关键词匹配,还要允许运营为每个 subreddit 单独配置允许/禁止名单;“遇到 429 必须退避”,意味着提交回复的任务不能像普通循环一样一梭子全部打出去,而要有队列和重试间隔。
这也是本文示例中把“扫描”和“提交”分成两个模块的原因。扫描模块需要高频读取,提交模块需要低频写入,二者混在一起会让限流处理非常难做。
2. 环境准备与 Reddit API 接入
2.1 注册应用与获取凭据
要让脚本使用 Reddit 账号执行操作,需要创建一组 API 凭据。访问 Reddit 的偏好页面下的 apps 页面,创建一个 script 类型的应用,提交后会得到两段关键内容:
- client_id:应用标识,界面里通常显示为一串短 ID。
- client_secret:应用密钥,只有创建者能看到,需要保存好。
同时,脚本还需要使用一个真实 Reddit 账号的用户名和密码。由于脚本通过 OAuth2 的 password grant 方式换取 token,因此账号密码要作为环境变量传入,不要写进代码。
| 配置项 | 说明 | 示例 |
|---|---|---|
| REDDIT_CLIENT_ID | 应用 ID | 1a2b3c4d5e |
| REDDIT_CLIENT_SECRET | 应用密钥 | 一段长随机字符串 |
| REDDIT_USERNAME | 执行自动回复的账号名 | my_bot_account |
| REDDIT_PASSWORD | 对应账号密码 | 不要写入代码或仓库 |
| REDDIT_USER_AGENT | 标识调用方的请求头 | invoice-tool/0.1 by my_bot_account |
需要注意一个坑:不要用日常交流的主账号做自动化测试。自动回复即使合规,也可能因为 subreddit 规则、图片处理、临时误报被版务封禁。推荐的做法是创建专用账号,并设置一个能表明身份的用户名。
2.2 安装依赖并用最小脚本验证连通性
Python 生态里最常用的 Reddit API 客户端是 PRAW。下面的依赖清单适用于多数本地开发环境,实际安装时以官方最新版本为准:
praw==7.7.1 python-dotenv==1.0.0配置信息放到.env文件,读取时使用 python-dotenv:
REDDIT_CLIENT_ID=1a2b3c4d5e REDDIT_CLIENT_SECRET=your_client_secret REDDIT_USERNAME=my_bot_account REDDIT_PASSWORD=your_account_password REDDIT_USER_AGENT=invoice-tool/0.1 by my_bot_account初始化客户端的代码:
# reddit_client.py import os import praw from dotenv import load_dotenv load_dotenv() def build_reddit_client() -> praw.Reddit: return praw.Reddit( client_id=os.environ["REDDIT_CLIENT_ID"], client_secret=os.environ["REDDIT_CLIENT_SECRET"], username=os.environ["REDDIT_USERNAME"], password=os.environ["REDDIT_PASSWORD"], user_agent=os.environ["REDDIT_USER_AGENT"], )验证连通性的最小脚本:
# check_connection.py from reddit_client import build_reddit_client reddit = build_reddit_client() print(reddit.user.me())运行后如果能打印出账号名,说明认证成功。这里最常见的坑是 User-Agent 设置过于简单,例如直接写成python:mybot version 1.0。Reddit 官方要求 User-Agent 能够唯一识别调用方。推荐格式包含应用名、版本号和执行账号名。
2.3 配置项目结构和环境变量
为了让示例可维护,建议先定义项目结构:
replyhey/ ├── .env ├── requirements.txt ├── reddit_client.py ├── schema.sql ├── scanner.py ├── draft.py ├── review.py ├── reply.py ├── database.py └── logs/database.py负责 SQLite 连接,生产环境可以替换成 PostgreSQL 或 MySQL。.env必须加入.gitignore,避免密钥提交到仓库。
这里要说清环境变量和配置文件的选择:环境变量适合放密钥,配置文件适合放可变更的业务规则,比如关键词、subreddit 名单、回复模板。二者不要混用。
3. 核心实现:扫描讨论、过滤相关帖子、生成草稿
3.1 定义帖子模型与 SQLite 表
先建一张表,记录扫描到的候选帖子和后续状态。SQLite 在本地够用,生产环境建议迁移到 PostgreSQL:
-- schema.sql CREATE TABLE IF NOT EXISTS submissions ( id TEXT PRIMARY KEY, subreddit TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', url TEXT, score INTEGER DEFAULT 0, relevance_score REAL DEFAULT 0, state TEXT DEFAULT 'pending', candidate_keywords TEXT DEFAULT '', created_utc INTEGER, scanned_utc INTEGER, reviewed_by TEXT, reviewed_utc INTEGER, reply_text TEXT, reply_id TEXT, error_message TEXT ); CREATE INDEX IF NOT EXISTS idx_submissions_state ON submissions(state); CREATE INDEX IF NOT EXISTS idx_submissions_subreddit ON submissions(subreddit);id 就是 Reddit 帖子的 base36 id,用它做主键可以天然去重。relevance_score 用来记录相关度排序依据,candidate_keywords 记录命中了哪些关键词,方便审核人员快速判断。
这里的核心设计是“状态机”:pending 表示已入队但未审核,approved 表示人工确认可以回复,rejected 表示放弃,replied 表示成功提交,skipped 表示因规则跳过,failed 表示提交失败。所有后续逻辑都围绕这个状态流转。
3.2 扫描指定 subreddit 并做关键词过滤
扫描逻辑按 subreddit 分组,对每个 subreddit 读取新帖,不需要把一个 subreddit 的几千篇历史帖全部拉下来。示例中先读取最近 50 篇新帖,再结合关键词判断:
# scanner.py import logging import sqlite3 import time from reddit_client import build_reddit_client logger = logging.getLogger(__name__) SUBREDDITS = ["freelance", "smallbusiness", "invoicing"] POSITIVE_KEYWORDS = ["invoice", "client", "payment", "get paid", "late payment"] NEGATIVE_KEYWORDS = ["I sell invoice paper", "invoice management software comparison"] MIN_SCORE = 2 SCAN_LIMIT = 50 def relevance(text: str) -> float: score = 0 matched = [] for kw in POSITIVE_KEYWORDS: if kw in text: score += 2 matched.append(kw) for kw in NEGATIVE_KEYWORDS: if kw in text: score -= 4 return score, matched def scan_once(conn: sqlite3.Connection) -> int: reddit = build_reddit_client() inserted = 0 for sub_name in SUBREDDITS: sub = reddit.subreddit(sub_name) for submission in sub.new(limit=SCAN_LIMIT): text = f"{submission.title}\n{submission.selftext}".lower() score, matched = relevance(text) if score < MIN_SCORE: continue cur = conn.execute( """ INSERT OR IGNORE INTO submissions (id, subreddit, title, body, url, score, relevance_score, candidate_keywords, created_utc, scanned_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( submission.id, sub_name, submission.title, submission.selftext[:2000], submission.url, submission.score, score, ",".join(matched), int(submission.created_utc), int(time.time()), ), ) inserted += cur.rowcount conn.commit() return inserted这里有几个关键判断。第一,为什么不直接给所有帖子回复?因为关键词匹配只是弱信号,它只能表示帖子在讨论相关主题,不能表示用户一定想要你的产品。第二,为什么要有 NEGATIVE_KEYWORDS?因为“发票管理软件对比”这类帖子里虽然出现关键词,大概率是竞品调研,不是商机。第三,为什么只扫描新帖而不是全站搜索?因为新帖时效性好,也更容易控制 API 频率。
最常踩的坑是直接对所有命中关键词的帖子生成回复,完全忽略 subreddit 自身的推广规则。比如某些 subreddit 明令禁止自我推广,你的关键词再准也不能回复。后续实现中要加入“subreddit 黑名单”和“每 subreddit 只允许回复 N 次/天”的策略。
3.3 生成回复草稿并落库
过滤后进入草稿生成阶段。为了说明完整流程,先用模板生成草稿,后续可以替换成大模型生成:
# draft.py import sqlite3 TEMPLATE = ( "我们是做 {product} 的开发者,正好看到你在讨论 {topic}。" "先说明一下身份,这个回复和我们的产品直接相关。" "如果你的场景和下面的描述一致,可以看一下官方文档:{doc_url}。" "如果没有帮助,直接忽略即可。" ) def generate_draft(submission_row, product="发票管理工具", doc_url="https://example.com/docs") -> str: topic = (submission_row["title"] or "").strip() return TEMPLATE.format(product=product, topic=topic, doc_url=doc_url)草稿为什么要明文保存,而不是直接提交?因为人工审核需要看到完整内容,而且如果生成模型出问题,至少知道发给用户的是什么。不要把草稿只放在内存里,否则程序重启后待审核队列就丢失了。
生成草稿的函数可以单独写,也可以合并在扫描流程中。推荐在扫描后生成草稿,再进入人工审核接口。
3.4 状态与幂等设计
幂等是这个系统最容易忽略的地方。Reddit 帖子不会因为程序重启而消失,同一篇帖子如果被扫描到两次,不能插入两条记录,更不能回复两次。
使用INSERT OR IGNORE可以避免重复入库。提交回复前,必须再检查一次数据库状态:
def can_reply(conn: sqlite3.Connection, submission_id: str) -> bool: row = conn.execute( "SELECT state FROM submissions WHERE id = ?", (submission_id,) ).fetchone() return row is not None and row[0] == "approved"同时建议在事务里更新状态:
conn.execute( "UPDATE submissions SET state = ?, reply_id = ?, error_message = ? WHERE id = ? AND state = ?", ("replied", reply_id, None, submission_id, "approved"), )这样即使提交过程中抛异常,也不会把状态改乱。另一个坑是提交后没有保存 reply_id,导致排查时不知道哪条评论是自己的。
4. 人工审核环节:不要绕过,也不要侥幸
4.1 命令行审核队列
审核无需一开始就上 Web 界面,命令行工具已经能支撑早期使用。示例代码:
# review.py import sqlite3 import sys def list_pending(conn: sqlite3.Connection): rows = conn.execute( """ SELECT id, subreddit, title, relevance_score, reply_text FROM submissions WHERE state = 'pending' ORDER BY relevance_score DESC LIMIT 20 """ ).fetchall() for row in rows: print(f"{row['id']} | {row['subreddit']} | score={row['relevance_score']}") print(f" title: {row['title']}") print(f" draft: {row['reply_text']}") def approve(conn: sqlite3.Connection, submission_id: str): conn.execute( "UPDATE submissions SET state = 'approved' WHERE id = ? AND state = 'pending'", (submission_id,), ) conn.commit() def reject(conn: sqlite3.Connection, submission_id: str): conn.execute( "UPDATE submissions SET state = 'rejected' WHERE id = ? AND state = 'pending'", (submission_id,), ) conn.commit()使用方法:
python review.py list python review.py approve 1abcde python review.py reject 1abcde为什么要这一步?因为程序判断的是“话题相关”,人判断的是“这个讨论是否适合以我们身份出现、语气是否合适、是否违反 subreddit 规则”。这一步是过滤器,也是防线。
4.2 审核通过后自动提交回复
提交逻辑是独立脚本,只处理 approved 状态的记录,并且要限制每次运行最多提交几条:
# reply.py import sqlite3 import time from reddit_client import build_reddit_client MAX_REPLIES_PER_RUN = 5 def reply_approved(conn: sqlite3.Connection) -> int: reddit = build_reddit_client() rows = conn.execute( "SELECT id, reply_text FROM submissions WHERE state = 'approved' LIMIT ?", (MAX_REPLIES_PER_RUN,), ).fetchall() replied = 0 for row in rows: submission_id = row["id"] try: submission = reddit.submission(id=submission_id) comment = submission.reply(row["reply_text"]) conn.execute( "UPDATE submissions SET state = 'replied', reply_id = ? WHERE id = ?", (comment.id, submission_id), ) conn.commit() replied += 1 except Exception as exc: conn.execute( "UPDATE submissions SET state = 'failed', error_message = ? WHERE id = ?", (str(exc), submission_id), ) conn.commit() time.sleep(2) return replied这里要关注几个现实限制。Reddit 会对新账号或低 Karma 账号的发布频率做限制,即使 API 返回成功,也未必能立刻看到效果。提交时如果帖子被锁定、删除或账号被封,会抛异常,要把异常信息记录到 error_message,而不是静默失败。
另一个高频坑是:提交前没有检查该帖子是否已经收到过自己的回复。两种实现思路:一是依赖数据库状态,二是提交前读取 submission 的评论列表做匹配。推荐两种都做,数据库状态作为主判断,评论列表作为兜底。
4.3 主循环与调度方式
本地开发时可以用一个循环同时调用扫描和提交,但生产环境建议拆开。扫描任务可以每 10 分钟跑一次,提交任务可以每 1 到 2 分钟跑一次,且提交任务必须比扫描任务更保守。
# run_local.py import logging import sqlite3 import time from scanner import scan_once from reply import reply_approved logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) SCAN_INTERVAL = 600 REPLY_INTERVAL = 60 def main(): conn = sqlite3.connect("replyhey.db") while True: try: inserted = scan_once(conn) replied = reply_approved(conn) if inserted or replied: logging.info("scan_inserted=%s replied=%s", inserted, replied) except Exception: logging.exception("unexpected error in main loop") time.sleep(min(SCAN_INTERVAL, REPLY_INTERVAL))这个循环只适合学习环境,因为它把两类任务写在同一个进程里,一旦扫描请求卡住,提交任务也会被拖慢。生产环境应该使用任务队列,例如 Redis + 定时任务,或者直接使用分布式调度平台。
5. 运行验证、日志和常见问题排查
5.1 本地运行和验证清单
第一步先验证认证,第二步验证扫描,第三步验证审核,第四步验证回复。不要第一次就把扫描和提交一起跑。
完成本地验证后,可以按以下清单确认是否具备真实性:
- 环境:
.env中客户端信息是否完整。 - 认证:执行
python check_connection.py能打印账号名。 - 扫描:执行
scan_once后看数据库是否新增记录。 - 审核:执行
python review.py list能看到待审核草稿。 - 提交:手动指定一条已 approved 的帖子,看 Reddit 网页端是否出现评论。
- 幂等:再次运行扫描,不应插入重复记录;再次运行提交,不应重复回复。
运行输出大致如下:
INFO root:scan_inserted=12 replied=0 INFO root:scan_inserted=0 replied=1如果出现 replied=0 且没有异常,通常是没有 approved 状态的记录,需要先在审核队列里处理。
5.2 日志字段与监控
日志不要只记录“运行成功”,至少记录:
| 字段 | 含义 | 示例 |
|---|---|---|
| subreddit | 来源板块 | freelance |
| submission_id | 帖子 ID | 1abcde |
| state | 当前状态 | approved |
| action | 操作 | reply_approved |
| error_message | 异常信息 | 403 Forbidden |
| elapsed_ms | 耗时 | 1203 |
| user | 操作账号 | my_bot_account |
日志格式可以选择 JSON 行,便于接入 ELK 或云日志服务。生产环境建议增加告警:连续提交失败超过 3 次、每个 subreddit 当日提交次数超过阈值、扫描插入数为 0 且持续超过 24 小时。
5.3 常见 API 错误对照表
| 现象 | 错误特征 | 常见原因 | 处理建议 |
|---|---|---|---|
| 认证失败 | 401 Unauthorized | client_id、secret、用户名或密码错误 | 检查环境变量,确认 script app 是否创建成功 |
| 禁止访问 | 403 Forbidden | 账号被封、subreddit 私有、无权限 |