Skip to content

Commit d871dac

Browse files
author
timolein74
committed
Initial commit: GitHub Radar ecosystem scanner - scans GitHub for new x402/ERC-8004/agentic commerce repos, scores by relevance, sends Telegram alerts every 6h
Made-with: Cursor
0 parents  commit d871dac

4 files changed

Lines changed: 376 additions & 0 deletions

File tree

.github/workflows/github-radar.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: GitHub Radar
2+
3+
on:
4+
schedule:
5+
- cron: '0 */6 * * *' # Every 6 hours
6+
workflow_dispatch: # Manual trigger
7+
8+
jobs:
9+
scan:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.11'
17+
18+
- name: Install dependencies
19+
run: pip install -r github-radar/requirements.txt
20+
21+
- name: Run GitHub Radar
22+
working-directory: github-radar
23+
env:
24+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25+
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
26+
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
27+
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
28+
SUPABASE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
29+
SCORE_THRESHOLD: '4'
30+
run: python github_radar.py

github_radar.py

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
"""
2+
GitHub Radar — x402/ERC-8004 ecosystem intelligence scanner.
3+
4+
Uses GitHub Search API to find new repositories matching AsterPay-relevant
5+
keywords, scores them, and sends Telegram notifications for high-scoring hits.
6+
Persists seen repos to Supabase to survive stateless CI runs.
7+
"""
8+
9+
import os
10+
import sys
11+
import json
12+
import base64
13+
import time
14+
from datetime import datetime, timedelta, timezone
15+
from typing import Any
16+
17+
import requests
18+
import yaml
19+
20+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
21+
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
22+
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
23+
SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://wibwhwxsoutngqyjvhgz.supabase.co")
24+
SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
25+
SCORE_THRESHOLD = int(os.environ.get("SCORE_THRESHOLD", "4"))
26+
27+
GITHUB_API = "https://api.github.com"
28+
UA = "github-radar/2.0 (AsterPay)"
29+
30+
# ── Scoring keywords ──────────────────────────────────────────────
31+
32+
SCORING_RULES: list[tuple[int, list[str]]] = [
33+
(3, ["x402"]),
34+
(3, ["erc-8004", "erc8004"]),
35+
(2, ["facilitator"]),
36+
(2, ["eurc", "eur settlement", "sepa"]),
37+
(2, ["mica", "mica compliance"]),
38+
(1, ["telecom", "telco", "voip", "camara"]),
39+
(1, ["marketplace", "bazaar"]),
40+
]
41+
42+
COMBO_RULES: list[tuple[int, list[str], list[str]]] = [
43+
(2, ["payment", "payments"], ["agent", "agentic", "ai agent", "ai-agent"]),
44+
]
45+
46+
47+
# ── GitHub helpers ─────────────────────────────────────────────────
48+
49+
def gh_headers() -> dict[str, str]:
50+
h = {"Accept": "application/vnd.github+json", "User-Agent": UA}
51+
if GITHUB_TOKEN:
52+
h["Authorization"] = f"Bearer {GITHUB_TOKEN}"
53+
return h
54+
55+
56+
def gh_search(query: str, created_after: str) -> list[dict[str, Any]]:
57+
"""Search GitHub repos created after a date. Returns up to 30 results."""
58+
full_q = f"{query} created:>{created_after}"
59+
params = {"q": full_q, "sort": "updated", "order": "desc", "per_page": 30}
60+
61+
try:
62+
resp = requests.get(
63+
f"{GITHUB_API}/search/repositories",
64+
headers=gh_headers(),
65+
params=params,
66+
timeout=15,
67+
)
68+
if resp.status_code == 403:
69+
print(f" Rate limited on search, sleeping 60s")
70+
time.sleep(60)
71+
return []
72+
if resp.status_code == 422:
73+
print(f" Search query rejected: {query}")
74+
return []
75+
resp.raise_for_status()
76+
return resp.json().get("items", [])
77+
except Exception as e:
78+
print(f" Search error: {e}")
79+
return []
80+
81+
82+
def gh_readme(owner: str, repo: str) -> str:
83+
"""Fetch README content (base64 decoded)."""
84+
try:
85+
resp = requests.get(
86+
f"{GITHUB_API}/repos/{owner}/{repo}/readme",
87+
headers=gh_headers(),
88+
timeout=10,
89+
)
90+
if resp.status_code != 200:
91+
return ""
92+
content = resp.json().get("content", "")
93+
return base64.b64decode(content).decode("utf-8", errors="ignore")[:5000]
94+
except Exception:
95+
return ""
96+
97+
98+
# ── Supabase persistence ──────────────────────────────────────────
99+
100+
def sb_headers() -> dict[str, str]:
101+
return {
102+
"apikey": SUPABASE_KEY,
103+
"Authorization": f"Bearer {SUPABASE_KEY}",
104+
"Content-Type": "application/json",
105+
"Prefer": "return=minimal",
106+
}
107+
108+
109+
def load_seen_ids() -> set[int]:
110+
"""Load already-seen repo IDs from Supabase."""
111+
if not SUPABASE_KEY:
112+
return set()
113+
try:
114+
resp = requests.get(
115+
f"{SUPABASE_URL}/rest/v1/github_radar_seen?select=repo_id",
116+
headers=sb_headers(),
117+
timeout=10,
118+
)
119+
if resp.status_code != 200:
120+
print(f" Supabase load error: {resp.status_code}")
121+
return set()
122+
return {r["repo_id"] for r in resp.json()}
123+
except Exception as e:
124+
print(f" Supabase load exception: {e}")
125+
return set()
126+
127+
128+
def save_seen(repo_id: int, full_name: str, score: int, reason: str) -> None:
129+
"""Save a scored repo to Supabase."""
130+
if not SUPABASE_KEY:
131+
return
132+
try:
133+
requests.post(
134+
f"{SUPABASE_URL}/rest/v1/github_radar_seen",
135+
headers={**sb_headers(), "Prefer": "return=minimal,resolution=merge-duplicates"},
136+
json={
137+
"repo_id": repo_id,
138+
"full_name": full_name,
139+
"score": score,
140+
"reason": reason,
141+
"scored_at": datetime.now(timezone.utc).isoformat(),
142+
"notified": score >= SCORE_THRESHOLD,
143+
},
144+
timeout=10,
145+
)
146+
except Exception as e:
147+
print(f" Supabase save error: {e}")
148+
149+
150+
# ── Scoring ────────────────────────────────────────────────────────
151+
152+
def score_repo(
153+
description: str,
154+
topics: list[str],
155+
readme: str,
156+
) -> tuple[int, list[str]]:
157+
"""Score a repo based on keyword analysis. Returns (score, matched_reasons)."""
158+
text = " ".join([description or "", " ".join(topics or []), readme or ""]).lower()
159+
total = 0
160+
reasons: list[str] = []
161+
162+
for points, keywords in SCORING_RULES:
163+
for kw in keywords:
164+
if kw in text:
165+
total += points
166+
reasons.append(kw)
167+
break
168+
169+
for points, group_a, group_b in COMBO_RULES:
170+
a_hit = any(k in text for k in group_a)
171+
b_hit = any(k in text for k in group_b)
172+
if a_hit and b_hit:
173+
total += points
174+
reasons.append("payment+agent combo")
175+
176+
if not description or len(description) < 10:
177+
total -= 1
178+
reasons.append("no description")
179+
180+
return total, reasons
181+
182+
183+
# ── Telegram ───────────────────────────────────────────────────────
184+
185+
def send_telegram(repo: dict[str, Any], score: int, reasons: list[str]) -> None:
186+
"""Send Telegram notification for a high-scoring repo."""
187+
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
188+
print(" Telegram not configured, skipping notification")
189+
return
190+
191+
full_name = repo.get("full_name", "unknown")
192+
html_url = repo.get("html_url", "")
193+
desc = repo.get("description", "") or ""
194+
lang = repo.get("language", "?") or "?"
195+
stars = repo.get("stargazers_count", 0)
196+
forks = repo.get("forks_count", 0)
197+
created = (repo.get("created_at", "") or "")[:10]
198+
199+
text = (
200+
f"🔭 *GitHub Radar — uusi repo*\n\n"
201+
f"*Repo:* [{full_name}]({html_url})\n"
202+
f"⭐ {stars} | 🍴 {forks} | 📅 {created}\n"
203+
f"*Kieli:* {lang}\n"
204+
f"*Kuvaus:* {desc[:200]}\n\n"
205+
f"*Score:* {score}\n"
206+
f"*Osumat:* {', '.join(reasons)}\n"
207+
)
208+
209+
try:
210+
resp = requests.post(
211+
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
212+
json={
213+
"chat_id": TELEGRAM_CHAT_ID,
214+
"text": text,
215+
"parse_mode": "Markdown",
216+
"disable_web_page_preview": True,
217+
},
218+
timeout=10,
219+
)
220+
if not resp.ok:
221+
print(f" Telegram error: {resp.status_code} {resp.text[:200]}")
222+
except Exception as e:
223+
print(f" Telegram exception: {e}")
224+
225+
226+
# ── Main scan ──────────────────────────────────────────────────────
227+
228+
def interval_to_date(interval: str) -> str:
229+
"""Convert interval name to ISO date string."""
230+
now = datetime.now(timezone.utc)
231+
if interval == "daily":
232+
dt = now - timedelta(days=1)
233+
elif interval == "weekly":
234+
dt = now - timedelta(weeks=1)
235+
elif interval == "monthly":
236+
dt = now - timedelta(days=30)
237+
else:
238+
dt = now - timedelta(days=1)
239+
return dt.strftime("%Y-%m-%d")
240+
241+
242+
def run_scan() -> None:
243+
searches_path = os.path.join(os.path.dirname(__file__), "searches.yaml")
244+
with open(searches_path, "r") as f:
245+
config = yaml.safe_load(f)
246+
247+
searches = config.get("searches", [])
248+
seen_ids = load_seen_ids()
249+
total_new = 0
250+
total_notified = 0
251+
252+
print(f"Loaded {len(seen_ids)} seen repos from Supabase")
253+
print(f"Running {len(searches)} searches (threshold={SCORE_THRESHOLD})...\n")
254+
255+
for search in searches:
256+
query = search["query"]
257+
interval = search.get("interval", "daily")
258+
created_after = interval_to_date(interval)
259+
260+
print(f"[{query}] (created>{created_after})")
261+
repos = gh_search(query, created_after)
262+
print(f" Found {len(repos)} repos")
263+
264+
for repo in repos:
265+
repo_id = repo.get("id")
266+
if not repo_id or repo_id in seen_ids:
267+
continue
268+
269+
if repo.get("fork"):
270+
continue
271+
272+
full_name = repo.get("full_name", "?")
273+
desc = repo.get("description", "") or ""
274+
topics = repo.get("topics", []) or []
275+
276+
readme = ""
277+
owner, name = full_name.split("/", 1) if "/" in full_name else ("", "")
278+
if owner:
279+
readme = gh_readme(owner, name)
280+
281+
score, reasons = score_repo(desc, topics, readme)
282+
seen_ids.add(repo_id)
283+
total_new += 1
284+
285+
save_seen(repo_id, full_name, score, ", ".join(reasons))
286+
287+
if score >= SCORE_THRESHOLD:
288+
print(f" 🔔 {full_name} score={score} [{', '.join(reasons)}]")
289+
send_telegram(repo, score, reasons)
290+
total_notified += 1
291+
else:
292+
print(f" ○ {full_name} score={score}")
293+
294+
# Respect GitHub Search API rate limit (30 req/min)
295+
time.sleep(2.5)
296+
297+
print(f"\nDone. {total_new} new repos scanned, {total_notified} notifications sent.")
298+
299+
300+
if __name__ == "__main__":
301+
if not GITHUB_TOKEN:
302+
print("WARNING: GITHUB_TOKEN not set — severe rate limits apply")
303+
if not SUPABASE_KEY:
304+
print("WARNING: SUPABASE_KEY not set — seen repos won't persist")
305+
306+
run_scan()

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests>=2.31.0
2+
pyyaml>=6.0

searches.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
searches:
2+
# Core x402
3+
- query: "x402"
4+
interval: daily
5+
- query: "erc-8004 agent"
6+
interval: daily
7+
- query: "erc8004"
8+
interval: daily
9+
10+
# Payments + stablecoin + EUR
11+
- query: "x402 payment facilitator"
12+
interval: weekly
13+
- query: "stablecoin EUR settlement"
14+
interval: weekly
15+
- query: "EURC payment"
16+
interval: weekly
17+
18+
# Agent commerce
19+
- query: "agentic commerce payment"
20+
interval: weekly
21+
- query: "mcp server payment"
22+
interval: weekly
23+
- query: "ai agent micropayment"
24+
interval: weekly
25+
26+
# Telecom + M2V
27+
- query: "telecom API payment"
28+
interval: weekly
29+
- query: "CAMARA open gateway"
30+
interval: monthly
31+
- query: "pay-to-reach"
32+
interval: monthly
33+
34+
# Kilpailija-seuranta
35+
- query: "x402 facilitator"
36+
interval: weekly
37+
- query: "agent trust score blockchain"
38+
interval: weekly

0 commit comments

Comments
 (0)