GitHub Issue Demand Signal (Nachfragesignal aus GitHub Issues)
Analysiere ein öffentliches GitHub-Repo eines Mitbewerbers. Rufe die offenen Issues ab. Filtere Rauschen lokal. Ordne sie 6 Nachfragekategorien zu. Bewerte sie anhand echter Interaktion. Erstelle einen priorisierten Bericht zu Nachfragelücken und einen GTM-Messaging-Brief.
Wichtige Regel: Jeder Issue-Titel im Output muss wörtlich aus der GitHub-API-Antwort stammen. Jeder Cluster-Theme-Name muss aus tatsächlichen Issue-Titeln in diesem Cluster abgeleitet werden. Wenn nach der Rauschfilterung weniger als 10 Issues übrig bleiben, stoppe und teile dem Benutzer mit – das Repo ist zu klein für eine zuverlässige Clusterung. Keine erfundenen Issue-Inhalte.
Häufige Fehler
| Der Agent wird wahrscheinlich... | Warum das falsch ist |
|---|---|
| Alle 200 rohen Issues ohne Filter an die KI senden | Bot-Issues, PRs und Null-Interaktions-Rauschen blähen die Clusterzahlen auf und verschwenden Kontext. Filtere zuerst lokal. |
| Die Kommentaranzahl als primäres Nachfragesignal verwenden | Kommentare enthalten Maintainer-Antworten, Off-Topic-Diskussionen und Spam. reactions["+1"] ist das sauberste Käufersignal. |
| Issue-Titel beim Zusammenfassen von Clustern umschreiben | Umschreiben verliert die genaue Sprache des Käufers, was der gesamte Zweck ist. Verwende wörtliche Issue-Titel. |
| Nach Schritt 4 fortfahren, wenn nach dem Filtern weniger als 10 Issues übrig sind | Weniger als 10 Issues bedeuten, dass das Repo zu klein ist oder die falsche URL angegeben wurde. Eine Clusterung mit spärlichen Daten erzeugt bedeutungslose Kategorien. |
| Pull Requests in die Analyse einbeziehen | Der GitHub Issues-Endpunkt gibt auch PRs zurück. Filtere, indem du prüfst, ob der pull_request-Schlüssel im Issue-Objekt fehlt. |
| Ein Issue als ignorierte Nachfrage markieren, ohne alle 3 Kriterien zu prüfen | Alle drei müssen zutreffen: Reaktionen >= 10, Alter >= 180 Tage, kein „planned/in-progress/roadmap“-Label. Fehlt ein Kriterium, ist das Issue disqualifiziert. |
Schritt 1: Einrichtung prüfen
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-nicht gesetzt, unauthentifiziertes Ratenlimit gilt (60 Anfragen/Std)}"
Wenn GITHUB_TOKEN nicht gesetzt ist: Fahre fort. Teile dem Benutzer mit: „GITHUB_TOKEN ist nicht gesetzt. Das unauthentifizierte Ratenlimit beträgt 60 Anfragen/Stunde – genug für 2 Abrufe, bevor das Limit erreicht ist. Füge für wiederholte Nutzung einen Token unter github.com/settings/tokens hinzu (keine Bereiche für öffentliche Repos erforderlich).“
Schritt 2: Eingabe sammeln
Du benötigst:
- GitHub-Repo-URL (z.B. https://github.com/owner/repo) oder owner/repo-Slug (z.B. facebook/react)
Parse Owner und Repo aus der Eingabe:
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# Normalisieren zu owner/repo
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("ERROR: Konnte GitHub-URL nicht parsen. Erwartetes 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: Eingabe muss eine GitHub-URL oder ein owner/repo-Slug sein (z.B. 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
Wenn das Parsen fehlschlägt: Stoppe. Frage: „Bitte gib das GitHub-Repo als URL (https://github.com/owner/repo) oder als owner/repo-Slug an (z.B. vercel/next.js).“
Schritt 3: Issues von der GitHub REST API abrufen
Rufe bis zu 200 Issues ab (2 Seiten mit je 100). Überprüfe das Ratenlimit nach dem ersten Abruf.
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:
# Ratenlimit nach der ersten Seite prüfen
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 "unbekannt"
print(f"ERROR: GitHub-Ratenlimit erschöpft. Setzt zurück um {reset_str}.")
print("Füge GITHUB_TOKEN zu deiner .env-Datei hinzu, um 5000 Anfragen/Std zu erhalten. Siehe github.com/settings/tokens (keine Bereiche erforderlich).")
sys.exit(1)
print(f"Ratenlimit verbleibend: {remaining}")
# Auf 404/403 prüfen
status = resp.status
if status == 404:
print(f"ERROR: Repo '{owner_repo}' nicht gefunden. Überprüfe die URL oder den Slug.")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"Seite {page}: leer, breche ab.")
break
all_issues.extend(page_data)
print(f"Seite {page}: {len(page_data)} Issues abgerufen")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"ERROR: Repo '{owner_repo}' nicht gefunden (404). Überprüfe die URL oder den Slug.")
elif e.code == 403:
print(f"ERROR: Zugriff verweigert (403). Das Repo könnte privat sein oder das Ratenlimit wurde erreicht.")
else:
print(f"ERROR: GitHub-API gab HTTP {e.code} zurück")
sys.exit(1)
except Exception as e:
print(f"ERROR: Fehler beim Abrufen von Seite {page}: {e}")
sys.exit(1)
print(f"Insgesamt rohe Issues abgerufen: {len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
Wenn GitHub 404 zurückgibt: Stoppe. Teile dem Benutzer mit: „Repo nicht gefunden. Überprüfe die URL oder den Slug und versuche es erneut. Private Repos sind ohne Authentifizierung und expliziten Repo-Bereich nicht zugänglich.“
Wenn GitHub 403 mit Ratenlimit-Header zurückgibt: Stoppe. Zeige die Rückkehrzeit und teile dem Benutzer mit, GITHUB_TOKEN hinzuzufügen.
Schritt 4: Lokale Vorverarbeitung – Filtern, Bewerten, Ignorierte Nachfrage erkennen
Kein API-Aufruf. Reines Python. Führe aus, bevor etwas an die KI gesendet wird.
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 überspringen (GitHub Issues-Endpunkt gibt auch PRs zurück)
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")
# Von Bots erstellte Issues überspringen
if user_type == "Bot":
noise_count += 1
noise_reasons["bot_author"] = noise_reasons.get("bot_author", 0) + 1
continue
# Bot-Muster-Titel überspringen
if noise_patterns.match(title):
noise_count += 1
noise_reasons["bot_title"] = noise_reasons.get("bot_title", 0) + 1
continue
# PR-als-Issue-Titel überspringen
if pr_title_patterns.match(title):
noise_count += 1
noise_reasons["pr_as_issue"] = noise_reasons.get("pr_as_issue", 0) + 1
continue
# Null-Signal-Issues überspringen
if reactions == 0 and comments == 0:
noise_count += 1
noise_reasons["zero_signal"] = noise_reasons.get("zero_signal", 0) + 1
continue
# Nachfragescore berechnen
demand_score = (reactions * 2) + (comments * 0.5)
# Ignorierte Nachfrage erkennen
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]
})
# Nach Demand-Score absteigend sortieren
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
print(f"Rohe Issues: {len(raw)}")
print(f"Rauschen gefiltert: {noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"Issues zur Analyse: {len(filtered)}")
if len(filtered) < 10:
print(f"ERROR: Nur {len(filtered)} Issues nach dem Filtern übrig. Dieses Repo hat zu wenige engagierte Issues für eine zuverlässige Clusterung.")
print("Versuche ein größeres Repo oder ein Repo mit mehr Community-Engagement.")
import sys; sys.exit(1)
# Zusammenfassung ignorierter Nachfrage
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"Ignorierte Nachfrage-Issues: {len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("Vorverarbeitung abgeschlossen.")
PYEOF
Wenn nach dem Filtern weniger als 10 Issues übrig sind: Stoppe. Teile dem Benutzer genau mit, wie viele Issues gefunden und gefiltert wurden und warum das Repo für eine zuverlässige Nachfrageclusterung zu klein ist.
Schritt 5: Issues clustern
Gib die gefilterten Issues zur Analyse aus:
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"Zu clusternde Issues: {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)} Reaktionen:{i['reactions_plus1']}] {i['title']}{labels_str}"
PYEOF
Klassifiziere jedes oben ausgegebene Issue in eine dieser 6 Kategorien:
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
Regeln:
- Klassifiziere jedes Issue in genau eine Kategorie
- Extrahiere eine ein-sätzige Schmerzaussage unter Verwendung der genauen Sprache des Benutzers aus dem Titel – nicht umschreiben
- Identifiziere 5–8 Cluster-Themen: kurze Phrasen (3–6 Wörter), die die dominierenden Beschwerdemuster über alle Issues hinweg erfassen
- Keine Gedankenstriche. Keine Marketingsprache.
Schreibe deine Analyse in /tmp/ghd-clusters.json mit dieser exakten Struktur:
{
"classified_issues": [
{"number": 123, "category": "feature_gap", "pain_statement": "Benutzer benötigen X, das es noch nicht gibt"}
],
"cluster_themes": [
{"theme_name": "Fehlende Exportoptionen", "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}
}
Bestätige nach dem Schreiben der Datei mit:
python3 -c "
import json
d = json.load(open('/tmp/ghd-clusters.json'))
print(f'Klassifiziert: {len(d[\"classified_issues\"])} Issues, {len(d[\"cluster_themes\"])} Themen')
print('Kategorien:', d['category_counts'])
"
Schritt 6: Messaging-Brief
Berechne den gesamten Nachfragescore pro Cluster und gib die Top 3 aus:
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"Top 3 Cluster für Messaging-Brief (Repo: {target}):")
for t in enriched_themes[:3]:
print(f"\n {t['theme_name']} ({t['category']}) -- Gesamtnachfrage: {t['total_demand_score']}")
for ti in t["top_issues"]:
print(f" #{ti['number']}: \"{ti['title']}\" ({ti['reactions']} Reaktionen)")
PYEOF
Generiere einen GTM-Messaging-Brief aus den oben ausgegebenen Top-3-Clustern.
Regeln:
- Jeder Positionierungswinkel muss das spezifische Cluster nennen, aus dem er stammt
- Jeder Outreach-Hook muss einen wörtlichen Issue-Titel in Anführungszeichen zitieren
- Überschriften müssen eine Zahl oder einen spezifischen benannten Schmerz enthalten – keine allgemeinen Aussagen
- Keine Gedankenstriche. Keine verbotenen Wörter: leistungsstark, robust, nahtlos, innovativ, revolutionär, optimieren, nutzen, transformieren
Schreibe deinen Brief in /tmp/ghd-brief.json mit dieser exakten Struktur:
…
[gekürzt für die Übersetzung]