GitHub Issue 需要シグナル分析
競合の公開GitHubリポジトリからIssueを取得し、ローカルでノイズをフィルタリング。6つの需要カテゴリにクラスタリングし、実際のエンゲージメントでスコアリング。ランク付けされた需要ギャップレポートとGTMメッセージングブリーフを出力します。
重要ルール: 出力されるすべてのIssueタイトルは、GitHub APIレスポンスからそのまま引用すること。各クラスタのテーマ名は、そのクラスタ内の実際のIssueタイトルから導出すること。ノイズフィルタリング後に10件未満のIssueしか残らなかった場合は、処理を停止しユーザーにその旨を伝えること。リポジトリが小さすぎて信頼性のあるクラスタリングができません。どの部分にも発明したIssueコンテンツを含めないこと。
よくある間違い
| AIエージェントがやりがちなこと | それが間違いである理由 |
|---|---|
| フィルタリングなしで200件の生のIssueをすべてAIに送信する | ボットIssue、PR、エンゲージメントゼロのノイズがクラスタ数を水増しし、コンテキストを浪費します。まずローカルでフィルタリングしてください。 |
| コメント数を主要な需要シグナルとして使用する | コメントにはメンテナの返信、オフトピックの議論、スパムが含まれます。reactions["+1"]が最もクリーンな購入シグナルです。 |
| クラスタを要約する際にIssueタイトルを言い換える | 言い換えると購入者の正確な言葉が失われ、それが分析の本質です。Issueタイトルはそのまま使用してください。 |
| フィルタリング後に10件未満のIssueしか残っていないのにステップ4以降を続行する | 10件未満の場合、リポジトリが小さすぎるか、間違ったURLが指定されています。データが少なすぎると意味のあるクラスタリングはできません。 |
| Pull Requestを分析に含める | GitHub IssuesエンドポイントはPRも返します。Issueオブジェクトにpull_requestキーがないことを確認してPRを除外してください。 |
| 3つの条件すべてを確認せずにIssueを無視された需要としてマークする | 3つすべてが真である必要があります:リアクション数 >= 10、経過日数 >= 180日、planned/in-progress/roadmapラベルなし。1つでも欠けるとそのIssueは無視された需要とはみなされません。 |
ステップ 1: セットアップ確認
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-未設定、未認証のレート制限が適用されます(60 req/hr)}"
GITHUB_TOKENが設定されていない場合: 処理を続行し、ユーザーに次のように伝えてください。「GITHUB_TOKENが設定されていません。未認証の場合、レート制限は1時間あたり60リクエストです。これは2回のフェッチで制限に達することを意味します。繰り返し使用するには、github.com/settings/tokens でトークンを追加してください(公開リポジトリにはスコープは不要です)。」
ステップ 2: 入力の収集
必要なもの:
- GitHubリポジトリURL(例:https://github.com/owner/repo)またはオーナー/レポ名(例:facebook/react)
入力からオーナーとレポを解析:
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# Normalize to owner/repo
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("ERROR: Could not parse GitHub URL. Expected format: 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("ERROR: Input must be a GitHub URL or owner/repo slug (e.g. 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)またはオーナー/レポ名(例:vercel/next.js)で指定してください。」
ステップ 3: GitHub REST APIからIssueを取得
最大200件のIssueを取得(2ページ、各100件)。最初のフェッチ後にレート制限を確認。
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:
# Check rate limit after first page
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"ERROR: GitHub APIのレート制限に達しました。リセット予定時刻: {reset_str}")
print(".envファイルにGITHUB_TOKENを追加すると、1時間あたり5000リクエストに増加します。github.com/settings/tokens(スコープ不要)を参照。")
sys.exit(1)
print(f"残りレート制限: {remaining}")
# Check for 404/403
status = resp.status
if status == 404:
print(f"ERROR: リポジトリ '{owner_repo}' が見つかりません。URLまたはスラッグを確認してください。")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"Page {page}: 空のため停止。")
break
all_issues.extend(page_data)
print(f"Page {page}: {len(page_data)} 件のIssueを取得")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"ERROR: リポジトリ '{owner_repo}' が見つかりません(404)。URLまたはスラッグを確認してください。")
elif e.code == 403:
print(f"ERROR: アクセスが拒否されました(403)。リポジトリがプライベートであるか、レート制限に達した可能性があります。")
else:
print(f"ERROR: GitHub APIがHTTP {e.code}を返しました")
sys.exit(1)
except Exception as e:
print(f"ERROR: ページ {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 Requestをスキップ(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
# 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"ERROR: フィルタリング後に残ったIssueは{len(filtered)}件のみです。このリポジトリは信頼性のあるクラスタリングを行うには Issue が少なすぎます。")
print("より大きなリポジトリ、またはコミュニティのエンゲージメントが多いリポジトリを試してください。")
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: Issueのクラスタリング
分析のためにフィルタリングされたIssueを表示:
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']} [score:{round(i['demand_score'],1)} reactions:{i['reactions_plus1']}] {i['title']}{labels_str}")
PYEOF
上記に表示された各Issueを、次の6つのカテゴリのいずれかに分類:
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
ルール:
- 各Issueを正確に1つのカテゴリに分類
- タイトルからユーザーの正確な表現を使用して、1文のペインステートメントを抽出。言い換えないこと
- 5〜8個のクラスタテーマを特定:すべてのIssueにわたる支配的な不満パターンを捉えた短いフレーズ(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']} reactions)")
PYEOF
上記で表示された上位3クラスタからGTMメッセージングブリーフを生成。
ルール:
- 各ポジショニングの視点は、それが由来する特定のクラスタを引用すること
- 各アウトリーチフックは、Issueタイトルを引用符で囲んでそのまま引用すること
- 見出しには数値または具体的なペイン名を含めること。一般的な表現は不可
- エムダッシュ(—)は使用禁止。禁止ワード:強力な、堅牢な、シームレスな、革新的な、ゲームチェンジャー、効率化、活用、変革
ブリーフを以下の正確な構造で /tmp/ghd-brief.json に書き込む:
...