From 5a96c258e1990acb075c9bb80dd862a626efed2d Mon Sep 17 00:00:00 2001 From: matcha Date: Mon, 5 Oct 2026 20:47:41 +0000 Subject: [PATCH] Add deterministic GitHub Trending feed --- trending.py | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 trending.py diff --git a/trending.py b/trending.py new file mode 100644 index 0000000..7a55db1 --- /dev/null +++ b/trending.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +import argparse, datetime as dt, html, json, pathlib, re, urllib.parse, urllib.request +from html import unescape + +ROOT = pathlib.Path(__file__).resolve().parent +PERIODS = [("daily","today"),("weekly","this week"),("monthly","this month")] + +def fetch(period): + url = "https://github.com/trending?" + urllib.parse.urlencode({"since": period}) + req = urllib.request.Request(url, headers={ + "User-Agent": "notplanned-trending/0.1", + "Accept": "text/html,application/xhtml+xml", + }) + with urllib.request.urlopen(req, timeout=20) as r: + return r.read().decode("utf-8", "replace") + +def clean(fragment): + fragment = re.sub(r"<[^>]+>", " ", fragment) + return " ".join(unescape(fragment).split()) + +def parse(period, body): + articles = re.findall(r']+class="[^"]*Box-row[^"]*"[^>]*>(.*?)', body, re.S|re.I) + rows = [] + period_phrase = dict(PERIODS)[period] + for rank, article in enumerate(articles, 1): + m = re.search(r']*>.*?]+href="/([^"#?]+/[^"#?]+)"', article, re.S|re.I) + if not m: + continue + repo = unescape(m.group(1)).strip() + if repo.count("/") != 1: + continue + desc = "" + pm = re.search(r']+class="[^"]*color-fg-muted[^"]*"[^>]*>(.*?)

', article, re.S|re.I) + if pm: + desc = clean(pm.group(1)) + lang = "" + lm = re.search(r'itemprop="programmingLanguage"[^>]*>(.*?)', article, re.S|re.I) + if lm: + lang = clean(lm.group(1)) + stars = None + sm = re.search(r'([0-9][0-9,]*)\s+stars\s+' + re.escape(period_phrase), clean(article), re.I) + if sm: + stars = int(sm.group(1).replace(",","")) + rows.append({ + "repo": repo, + "url": f"https://github.com/{repo}", + "rank": rank, + "description": desc, + "language": lang, + "stars_gained": stars, + }) + return rows + +def build(): + now = dt.datetime.now(dt.timezone.utc).isoformat() + periods = {} + for period, _ in PERIODS: + periods[period] = parse(period, fetch(period)) + + merged = {} + for period, rows in periods.items(): + for row in rows: + x = merged.setdefault(row["repo"], { + "repo": row["repo"], + "url": row["url"], + "description": row["description"], + "language": row["language"], + "daily_rank": None, "weekly_rank": None, "monthly_rank": None, + "daily_stars": None, "weekly_stars": None, "monthly_stars": None, + }) + x[f"{period}_rank"] = row["rank"] + x[f"{period}_stars"] = row["stars_gained"] + if not x["description"] and row["description"]: + x["description"] = row["description"] + if not x["language"] and row["language"]: + x["language"] = row["language"] + + for x in merged.values(): + x["periods"] = sum(x[f"{p}_rank"] is not None for p, _ in PERIODS) + # Mechanical prominence only: preserve GitHub rank, favor appearing in more windows. + x["prominence"] = sum( + (26 - min(x[f"{p}_rank"], 25)) * w + for p, w in [("daily",3),("weekly",2),("monthly",1)] + if x[f"{p}_rank"] is not None + ) + + items = sorted(merged.values(), key=lambda x: ( + -x["periods"], + x["daily_rank"] if x["daily_rank"] is not None else 999, + x["weekly_rank"] if x["weekly_rank"] is not None else 999, + x["monthly_rank"] if x["monthly_rank"] is not None else 999, + x["repo"].lower(), + )) + return {"generated_at": now, "source":"https://github.com/trending", "periods": periods, "merged": items} + +def card(x): + def badge(label, rank, stars): + if rank is None: return "" + gain = f" · +{stars:,}" if stars is not None else "" + return f'{label} #{rank}{gain}' + badges = "".join([ + badge("day", x["daily_rank"], x["daily_stars"]), + badge("week", x["weekly_rank"], x["weekly_stars"]), + badge("month", x["monthly_rank"], x["monthly_stars"]), + ]) + desc = html.escape(x["description"]) + lang = html.escape(x["language"] or "—") + repo = html.escape(x["repo"]) + url = html.escape(x["url"], quote=True) + return f""" +

{repo}

{x['periods']}/3
+

{desc}

+
{badges}
+
{lang}
+
""" + +def render(out_html, out_json): + data = build() + tpl = (ROOT/"trending-template.html").read_text() + counts = {p: len(data["periods"][p]) for p, _ in PERIODS} + overlap = sum(1 for x in data["merged"] if x["periods"] >= 2) + page = (tpl + .replace("__UPDATED__", data["generated_at"][:16].replace("T"," ")) + .replace("__DAILY__", str(counts["daily"])) + .replace("__WEEKLY__", str(counts["weekly"])) + .replace("__MONTHLY__", str(counts["monthly"])) + .replace("__OVERLAP__", str(overlap)) + .replace("__CARDS__", "\n".join(card(x) for x in data["merged"])) + ) + pathlib.Path(out_html).write_text(page) + pathlib.Path(out_json).write_text(json.dumps(data, indent=2)+"\n") + print(json.dumps({"daily":counts["daily"],"weekly":counts["weekly"],"monthly":counts["monthly"],"merged":len(data["merged"]),"overlap":overlap})) + +if __name__ == "__main__": + p=argparse.ArgumentParser() + p.add_argument("--html-out", required=True) + p.add_argument("--json-out", required=True) + a=p.parse_args() + render(a.html_out,a.json_out)