GitHub Issue 需求信号
获取竞争对手的公开 GitHub 仓库。获取它们的 open issues。本地过滤噪音。聚类为 6 个需求类别。根据真实互动评分。输出排序后的需求差距报告和 GTM 信息简报。
关键规则: 输出中的每个 issue 标题必须直接来自 GitHub API 响应。每个集群主题名称必须从该集群中的实际 issue 标题中衍生。如果噪音过滤后剩余少于 10 个 issue,则停止并告知用户——该仓库对于可靠的聚类来说太小。任何地方都不得有编造的 issue 内容。
常见错误
| 代理可能会…… | 为什么这是错误的 |
|---|---|
| 不经过滤就将所有 200 个原始 issues 发送给 AI | 机器人 issue、PR 和零互动噪音会夸大集群计数并浪费上下文。应首先在本地过滤。 |
| 使用评论数作为主要需求信号 | 评论包括维护者回复、离题讨论和垃圾信息。reactions["+1"] 是最清晰的买家信号。 |
| 在总结集群时改写 issue 标题 | 改写会丢失买家的确切措辞,而这正是重点。应使用原样的 issue 标题。 |
| 在过滤后剩余少于 10 个 issue 时继续执行步骤 4 之后的操作 | 少于 10 个 issue 意味着仓库太小或给出的 URL 错误。在稀疏数据上进行聚类会产生无意义的类别。 |
| 在分析中包含 pull request | GitHub Issues 端点也会返回 PR。应通过检查 issue 对象上是否不存在 pull_request 键来过滤。 |
| 在未检查所有 3 个条件的情况下将 issue 标记为被忽略的需求 | 所有三个条件都必须为真:reactions >= 10,age >= 180 天,没有 planned/in-progress/roadmap 标签。缺少一个条件则 disqualify 该 issue。 |
步骤 1:设置检查
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-未设置,未认证的速率限制适用(60 次请求/小时)}"
如果 GITHUB_TOKEN 未设置: 继续。告知用户:“GITHUB_TOKEN 未设置。未认证的速率限制为 60 次请求/小时——足够在达到限制前获取 2 次。如需重复使用,请在 github.com/settings/tokens 添加一个令牌(公共仓库不需要任何权限范围)。”
步骤 2:收集输入
您需要:
- GitHub 仓库 URL(例如 https://github.com/owner/repo)或 owner/repo 缩写(例如 facebook/react)
从输入中解析 owner 和 repo:
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# 规范化为 owner/repo
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("错误:无法解析 GitHub URL。预期格式:https://github.com/owner/repo")
sys.exit(1)
owner, repo = m.group(1), m.group(2).rstrip("/")
elif "/" in raw:
parts = raw.strip().split("/")
owner, repo = parts[0], parts[1]
else:
print("错误:输入必须是 GitHub URL 或 owner/repo 缩写(例如 vercel/next.js)")
sys.exit(1)
print(f"Owner: {owner}")
print(f"Repo: {repo}")
with open("/tmp/ghd-target.txt", "w") as f:
f.write(f"{owner}/{repo}")
PYEOF
如果解析失败: 停止。询问:“请提供 GitHub 仓库的 URL (https://github.com/owner/repo) 或 owner/repo 缩写 (例如 vercel/next.js)。”
步骤 3:从 GitHub REST API 获取 Issues
获取最多 200 个 issue(每页 100 个,共 2 页)。在第一次获取后检查速率限制。
python3 << 'PYEOF'
import json, urllib.request, os, sys
from datetime import datetime, timezone
target = open("/tmp/ghd-target.txt").read().strip()
owner_repo = target
token = os.environ.get("GITHUB_TOKEN", "")
headers = {"Accept": "application/vnd.github+json", "User-Agent": "gh-issue-demand-signal/1.0"}
if token:
headers["Authorization"] = f"Bearer {token}"
all_issues = []
rate_limit_hit = False
for page in [1, 2]:
url = f"https://api.github.com/repos/{owner_repo}/issues?state=open&per_page=100&page={page}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
# 在第一页后检查速率限制
if page == 1:
remaining = int(resp.headers.get("X-RateLimit-Remaining", 999))
reset_ts = resp.headers.get("X-RateLimit-Reset", "")
if remaining == 0:
reset_str = datetime.fromtimestamp(int(reset_ts), tz=timezone.utc).strftime("%H:%M UTC") if reset_ts else "未知"
print(f"错误:GitHub 速率限制已耗尽。重置时间为 {reset_str}。")
print("请将 GITHUB_TOKEN 添加到您的 .env 文件中,以获得 5000 次请求/小时。详见 github.com/settings/tokens(不需要任何权限范围)。")
sys.exit(1)
print(f"剩余速率限制:{remaining}")
# 检查 404/403
status = resp.status
if status == 404:
print(f"错误:仓库 '{owner_repo}' 未找到。请检查 URL 或缩写。")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"第 {page} 页:为空,停止。")
break
all_issues.extend(page_data)
print(f"第 {page} 页:已获取 {len(page_data)} 个 issue")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"错误:仓库 '{owner_repo}' 未找到 (404)。请检查 URL 或缩写。")
elif e.code == 403:
print(f"错误:访问被拒绝 (403)。仓库可能为私有或已达到速率限制。")
else:
print(f"错误:GitHub API 返回 HTTP {e.code}")
sys.exit(1)
except Exception as e:
print(f"错误:无法获取第 {page} 页:{e}")
sys.exit(1)
print(f"已获取的原始 issue 总数:{len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
如果 GitHub 返回 404: 停止。告知用户:“仓库未找到。请检查 URL 或缩写后重试。没有认证和明确的仓库权限范围时,无法访问私有仓库。”
如果 GitHub 返回 403 并带有速率限制头信息: 停止。显示重置时间并告知用户添加 GITHUB_TOKEN。
步骤 4:本地预处理——过滤、评分、检测被忽略的需求
无需 API 调用。纯 Python。在对 AI 进行任何操作之前运行。
python3 << 'PYEOF'
import json, re
from datetime import datetime, timezone
raw = json.load(open("/tmp/ghd-raw-issues.json"))
target = open("/tmp/ghd-target.txt").read().strip()
now = datetime.now(tz=timezone.utc)
noise_patterns = re.compile(
r"^(chore|deps|bump|renovate|dependabot|release|ci|build|revert)[\s:\[]",
re.IGNORECASE
)
pr_title_patterns = re.compile(
r"^(feat|fix|refactor|docs|test|style|perf|chore)(\(.+\))?:",
re.IGNORECASE
)
filtered = []
noise_count = 0
noise_reasons = {}
for issue in raw:
# 跳过 pull requests(GitHub Issues 端点也会返回 PR)
if "pull_request" in issue:
noise_count += 1
noise_reasons["pull_request"] = noise_reasons.get("pull_request", 0) + 1
continue
title = issue.get("title", "")
reactions = issue.get("reactions", {}).get("+1", 0)
comments = issue.get("comments", 0)
user_type = (issue.get("user") or {}).get("type", "User")
# 跳过机器人编写的 issue
if user_type == "Bot":
noise_count += 1
noise_reasons["bot_author"] = noise_reasons.get("bot_author", 0) + 1
continue
# 跳过机器人模式的标题
if noise_patterns.match(title):
noise_count += 1
noise_reasons["bot_title"] = noise_reasons.get("bot_title", 0) + 1
continue
# 跳过作为 issue 的 PR 标题
if pr_title_patterns.match(title):
noise_count += 1
noise_reasons["pr_as_issue"] = noise_reasons.get("pr_as_issue", 0) + 1
continue
# 跳过零信号的 issue
if reactions == 0 and comments == 0:
noise_count += 1
noise_reasons["zero_signal"] = noise_reasons.get("zero_signal", 0) + 1
continue
# 计算需求分数
demand_score = (reactions * 2) + (comments * 0.5)
# 检测被忽略的需求
created_at = issue.get("created_at", "")
if created_at:
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
age_days = (now - created).days
else:
age_days = 0
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
has_planned_label = any(
kw in label for label in labels
for kw in ["in-progress", "planned", "roadmap", "wip", "in progress"]
)
ignored_demand = (
reactions >= 10 and
age_days >= 180 and
not has_planned_label
)
filtered.append({
"number": issue["number"],
"title": title,
"url": issue.get("html_url", f"https://github.com/{target}/issues/{issue['number']}"),
"reactions_plus1": reactions,
"comments": comments,
"demand_score": demand_score,
"age_days": age_days,
"labels": labels,
"ignored_demand": ignored_demand,
"body_snippet": (issue.get("body") or "")[:300]
})
# 按需求分数降序排序
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
print(f"原始 issue 数:{len(raw)}")
print(f"噪音过滤数:{noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"待分析的 issue 数:{len(filtered)}")
if len(filtered) < 10:
print(f"错误:过滤后仅剩下 {len(filtered)} 个 issue。此仓库的活跃 issue 数量太少,无法进行可靠的聚类。")
print("请尝试更大的仓库或社区参与度更高的仓库。")
import sys; sys.exit(1)
# 被忽略的需求摘要
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"被忽略的需求 issue 数:{len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("预处理完成。")
PYEOF
如果过滤后剩余少于 10 个 issue: 停止。准确告知用户找到了多少个 issue、过滤了多少个,以及为什么该仓库对于可靠的需求聚类来说太小。
步骤 5:聚类 Issues
打印过滤后的 issues 以供分析:
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
target = open("/tmp/ghd-target.txt").read().strip()
issue_list = filtered[:150]
print(f"仓库:{target}")
print(f"待聚类的 issue 数:{len(issue_list)}")
print()
for i in issue_list:
labels_str = f" [{', '.join(i['labels'][:3])}]" if i['labels'] else ""
print(f"#{i['number']} [分数:{round(i['demand_score'],1)} 反应:{i['reactions_plus1']}] {i['title']}{labels_str}")
PYEOF
将上面打印的每个 issue 分类到以下 6 个类别之一:
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
规则:
- 将每个 issue 分类到恰好一个类别
- 使用标题中用户的确切措辞提取一个句子的痛点描述——不要改写
- 确定 5-8 个集群主题:捕获所有 issues 中主导投诉模式的短短语(3-6 个词)
- 不要使用破折号。不要使用营销语言。
将您的分析写入 /tmp/ghd-clusters.json,结构如下:
{
"classified_issues": [
{"number": 123, "category": "feature_gap", "pain_statement": "用户需要尚不存在的 X"}
],
"cluster_themes": [
{"theme_name": "缺失导出选项", "category": "feature_gap", "issue_numbers": [123, 456, 789]}
],
"category_counts": {"feature_gap": 5, "bug_pattern": 3, "ux_complaint": 4, "performance": 2, "integration_missing": 6, "docs_missing": 1}
}
写入文件后,确认:
python3 -c "
import json
d = json.load(open('/tmp/ghd-clusters.json'))
print(f'已分类:{len(d[\"classified_issues\"])} 个 issue, {len(d[\"cluster_themes\"])} 个主题')
print('类别:', d['category_counts'])
"
步骤 6:信息简报
计算每个聚类的总需求分数并打印前 3 名:
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
clusters = json.load(open("/tmp/ghd-clusters.json"))
target = open("/tmp/ghd-target.txt").read().strip()
demand_by_issue = {i["number"]: i["demand_score"] for i in filtered}
issue_titles = {i["number"]: i["title"] for i in filtered}
issue_reactions = {i["number"]: i["reactions_plus1"] for i in filtered}
enriched_themes = []
for theme in clusters.get("cluster_themes", []):
issue_nums = theme.get("issue_numbers", [])
total_demand = sum(demand_by_issue.get(n, 0) for n in issue_nums)
top_issues = sorted(issue_nums, key=lambda n: demand_by_issue.get(n, 0), reverse=True)[:3]
enriched_themes.append({
"theme_name": theme["theme_name"],
"category": theme["category"],
"issue_count": len(issue_nums),
"total_demand_score": round(total_demand, 1),
"top_issues": [
{"number": n, "title": issue_titles.get(n, ""), "reactions": issue_reactions.get(n, 0)}
for n in top_issues
]
})
enriched_themes.sort(key=lambda x: x["total_demand_score"], reverse=True)
json.dump(enriched_themes, open("/tmp/ghd-enriched-themes.json", "w"), indent=2)
print(f"信息简报的前 3 个聚类(仓库:{target}):")
for t in enriched_themes[:3]:
print(f"\n {t['theme_name']} ({t['category']}) -- 总需求:{t['total_demand_score']}")
for ti in t["top_issues"]:
print(f" #{ti['number']}: \"{ti['title']}\" ({ti['reactions']} 个反应)")
PYEOF
根据上面打印的前 3 个聚类生成 GTM 信息简报。
规则:
- 每个定位角度必须引用其来源的具体集群
- 每个外展 hook 必须引用一个原样的 issue 标题,并用引号括起来
- 标题必须包含数字或特定的命名痛点——不能使用通用语句
- 不要使用破折号。禁止使用的词语:powerful, robust, seamless, innovative, game-changing, streamline, leverage, transform
将您的简报写入 /tmp/ghd-brief.json,结构如下:
...
[已截断以进行翻译]