GitHub Issue Demand Signal (GitHub 이슈 수요 신호 분석)
경쟁사의 공개 GitHub 저장소를 분석합니다. 오픈 이슈를 가져와 로컬에서 노이즈를 필터링하고, 6가지 수요 카테고리로 클러스터링합니다. 실제 참여도를 기반으로 점수를 매기고, 순위가 매겨진 수요 격차 보고서와 GTM 메시징 브리프를 출력합니다.
중요 규칙: 출력의 모든 이슈 제목은 GitHub API 응답에서 그대로 가져와야 합니다. 모든 클러스터 테마 이름은 해당 클러스터의 실제 이슈 제목에서 파생되어야 합니다. 노이즈 필터링 후 10개 미만의 이슈만 남으면 중단하고 사용자에게 알립니다. 저장소가 너무 작아 신뢰할 수 있는 클러스터링이 불가능하기 때문입니다. 어디에서도 발명된 이슈 내용을 사용해서는 안 됩니다.
일반적인 실수
| 에이전트가 하려는 행동 | 왜 틀렸는지 |
|---|---|
| 필터링 없이 200개의 원시 이슈를 모두 AI로 전송 | 봇 이슈, PR, 참여도가 0인 노이즈가 클러스터 수를 부풀리고 컨텍스트를 낭비합니다. 먼저 로컬에서 필터링하세요. |
| 기본 수요 신호로 댓글 수를 사용 | 댓글에는 유지 관리자의 응답, 주제에서 벗어난 토론, 스팸이 포함됩니다. reactions["+1"]이 가장 깨끗한 구매자 신호입니다. |
| 클러스터를 요약할 때 이슈 제목을 의역 | 의역은 구매자의 정확한 언어를 잃게 되며, 이것이 전부입니다. 원문 그대로의 이슈 제목을 사용하세요. |
| 필터링 후 10개 미만의 이슈가 남았는데도 Step 4를 계속 진행 | 10개 미만의 이슈는 저장소가 너무 작거나 잘못된 URL이 제공되었음을 의미합니다. 희소한 데이터로 클러스터링하면 의미 없는 카테고리가 생성됩니다. |
| 분석에 풀 리퀘스트 포함 | GitHub Issues 엔드포인트는 PR도 반환합니다. 이슈 객체에 pull_request 키가 있는지 확인하여 필터링하세요. |
| 3가지 기준을 모두 확인하지 않고 이슈를 무시된 수요로 표시 | 세 가지 모두 참이어야 합니다: reactions >= 10, age >= 180일, planned/in-progress/roadmap 레이블 없음. 하나의 기준이라도 충족하지 못하면 해당 이슈는 자격을 상실합니다. |
Step 1: 설정 확인
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-설정되지 않음, 인증되지 않은 속도 제한 적용 (60회/시간)}"
GITHUB_TOKEN이 설정되지 않은 경우: 계속합니다. 사용자에게 알립니다: "GITHUB_TOKEN이 설정되지 않았습니다. 인증되지 않은 속도 제한은 시간당 60회 요청입니다. 이는 제한에 도달하기 전에 2번의 가져오기를 수행하기에 충분합니다. 반복 사용을 위해 github.com/settings/tokens (공개 저장소의 경우 범위 필요 없음)에서 토큰을 추가하세요."
Step 2: 입력 수집
다음이 필요합니다:
- GitHub 저장소 URL (예: https://github.com/owner/repo) 또는 owner/repo 슬러그 (예: facebook/react)
입력에서 소유자와 저장소를 구문 분석합니다:
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("ERROR: 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("ERROR: 입력은 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)로 제공해주세요."
Step 3: GitHub REST API에서 이슈 가져오기
최대 200개의 이슈를 가져옵니다 (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 "unknown"
print(f"ERROR: GitHub 속도 제한이 소진되었습니다. {reset_str}에 재설정됩니다.")
print("5000회/시간을 얻으려면 .env 파일에 GITHUB_TOKEN을 추가하세요. github.com/settings/tokens (범위 필요 없음)를 참조하세요.")
sys.exit(1)
print(f"남은 속도 제한: {remaining}")
# 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}: 비어 있음, 중단합니다.")
break
all_issues.extend(page_data)
print(f"페이지 {page}: {len(page_data)}개의 이슈를 가져왔습니다")
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"가져온 총 원시 이슈 수: {len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
GitHub가 404를 반환하는 경우: 중단합니다. 사용자에게 알립니다: "저장소를 찾을 수 없습니다. URL 또는 슬러그를 확인하고 다시 시도하세요. 비공개 저장소는 인증 및 명시적인 저장소 범위 없이 접근할 수 없습니다."
GitHub가 속도 제한 헤더와 함께 403을 반환하는 경우: 중단합니다. 재설정 시간을 표시하고 사용자에게 GITHUB_TOKEN을 추가하라고 알립니다.
Step 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:
# 풀 리퀘스트 건너뛰기 (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")
# 봇이 작성한 이슈 건너뛰기
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
# 제로 신호 이슈 건너뛰기
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"원시 이슈: {len(raw)}")
print(f"노이즈 필터링됨: {noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"분석 대상 이슈: {len(filtered)}")
if len(filtered) < 10:
print(f"ERROR: 필터링 후 {len(filtered)}개의 이슈만 남았습니다. 이 저장소는 신뢰할 수 있는 클러스터링을 위한 참여 이슈가 너무 적습니다.")
print("더 큰 저장소나 커뮤니티 참여가 더 많은 저장소를 시도해보세요.")
import sys; sys.exit(1)
# 무시된 수요 요약
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"무시된 수요 이슈: {len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("전처리 완료.")
PYEOF
필터링 후 10개 미만의 이슈가 남은 경우: 중단합니다. 정확히 몇 개의 이슈를 찾았고 필터링했는지, 그리고 그 저장소가 신뢰할 수 있는 수요 클러스터링에 비해 너무 작은 이유를 사용자에게 알립니다.
Step 5: 이슈 클러스터링
분석을 위해 필터링된 이슈를 출력합니다:
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"Repo: {target}")
print(f"클러스터링할 이슈: {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
위에 출력된 각 이슈를 다음 6가지 카테고리 중 하나로 분류하십시오:
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
규칙:
- 각 이슈를 정확히 하나의 카테고리로 분류
- 제목에서 사용자의 정확한 언어를 사용하여 1문장의 고통 진술문 추출 -- 의역하지 말 것
- 모든 이슈에서 지배적인 불만 패턴을 포착하는 5-8개의 클러스터 테마 식별: 짧은 구문 (3-6 단어)
- em 대시 사용 금지. 마케팅 언어 사용 금지.
분석 결과를 다음 정확한 구조로 /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\"])}개의 이슈, {len(d[\"cluster_themes\"])}개의 테마')
print('카테고리:', d['category_counts'])
"
Step 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개 클러스터 (repo: {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 메시징 브리프를 생성합니다.
규칙:
- 각 포지셔닝 앵글은 그것이 나온 특정 클러스터를 인용해야 함
- 각 아웃리치 훅은 따옴표로 묶인 그대로의 이슈 제목을 인용해야 함
- 헤드라인에는 숫자나 특정 명명된 고통이 포함되어야 함 -- 일반적인 진술 금지
- em 대시 사용 금지. 금지된 단어: powerful, robust, seamless, innovative, game-changing, streamline, leverage, transform
브리프를 다음 정확한 구조로 /tmp/ghd-brief.json에 작성하십시오:
{
"repo": "owner/repo",
"positioning_angles": [
{
"cluster": "누락된 내보내기 옵션",
"headline": "사용자가 요구하는 3가지 내보내기 형식",
"positioning": "경쟁사는 기본 내보내기를 제공하지만, 사용자들은 [특정 형식]을 지원하지 않아 불편을 겪고 있습니다",
"outreach_hook": "\"[이슈 제목 그대로]\"와 같은 요청이 {count}건의 reaction을 받았습니다"
}
],
"competitor_weaknesses": [
{
"weakness": "예: 경쟁사는 장기간 방치된 기능 요청이 많으며, 이는 로드맵이 사용자 요구와 일치하지 않음을 나타냅니다",
"signal": "{count}개의 무시된 수요 이슈",
"opportunity": "이러한 요청을 해결함으로써 차별화된 가치를 창출할 수 있습니다"
}
]
}
파일을 작성한 후 다음으로 확인합니다:
python3 -c "
import json
b = json.load(open('/tmp/ghd-brief.json'))
print(f'포지셔닝 앵글: {len(b[\"positioning_angles\"])}')
print(f'경쟁사 약점: {len(b[\"competitor_weaknesses\"])}')
"
Step 7: 최종 보고서
분석 결과를 마크다운 보고서로 제시하십시오.
cat << 'REPORTEOF' > /tmp/ghd-report.md
# GitHub 수요 격차 보고서: owner/repo
## 저장소 개요
- **저장소**: owner/repo
- **분석된 오픈 이슈**: {count}
- **노이즈 필터링됨**: {count} (PR, 봇, 제로 시그널)
- **무시된 수요 이슈**: {count} (10개 이상의 reactions, 180일 이상, 로드맵에 없음)
## 카테고리별 분석
| 카테고리 | 이슈 수 | 설명 |
|---|---|---|
| 기능 격차 (Feature Gap) | {n} | 요청되었지만 존재하지 않는 기능 |
| 버그 패턴 (Bug Pattern) | {n} | 일관된 버그 보고서 |
| UX 불만 (UX Complaint) | {n} | 사용성 문제 |
| 성능 (Performance) | {n} | 속도/리소스 문제 |
| 누락된 통합 (Integration Missing) | {n} | 누락된 타사 통합 |
| 누락된 문서 (Docs Missing) | {n} | 문서 요청 |
## 상위 수요 신호 (점수 기준)
상위 5개의 가장 높은 점수를 받은 이슈를 여기에 나열하십시오. 각 이슈는 번호, 제목, 카테고리, 그리고 원래 reactions 수를 포함해야 합니다.
## 상위 수요 클러스터
각 클러스터에 대해 다음을 포함하십시오:
- 클러스터 이름 (총 수요 점수)
- 카테고리
- 이슈 수
- 상위 3개 이슈 (제목은 그대로)
## GTM 메시징 브리프
Step 6에서 생성된 포지셔닝 앵글과 경쟁사 약점을 여기에 포함하십시오.
## 실행 권장 사항
사용자가 취할 수 있는 실행 가능한 다음 단계를 3-5개의 글머리 기호로 제공하십시오.
---
*보고서는 [owner/repo](https://github.com/owner/repo)의 GitHub 이슈 데이터와 API 응답에서 가져온 그대로의 이슈 제목을 기반으로 생성되었습니다.*
REPORTEOF
Step 6에서 생성된 /tmp/ghd-brief.json의 데이터를 사용하여 위 템플릿의 자리 표시자를 실제 값으로 채웁니다. 플레이스홀더를 제거하고 마크다운 형식의 깔끔한 최종 보고서를 출력합니다.