140 lines
5.6 KiB
Python
140 lines
5.6 KiB
Python
#!/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'<article[^>]+class="[^"]*Box-row[^"]*"[^>]*>(.*?)</article>', body, re.S|re.I)
|
|
rows = []
|
|
period_phrase = dict(PERIODS)[period]
|
|
for rank, article in enumerate(articles, 1):
|
|
m = re.search(r'<h2[^>]*>.*?<a[^>]+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'<p[^>]+class="[^"]*color-fg-muted[^"]*"[^>]*>(.*?)</p>', article, re.S|re.I)
|
|
if pm:
|
|
desc = clean(pm.group(1))
|
|
lang = ""
|
|
lm = re.search(r'itemprop="programmingLanguage"[^>]*>(.*?)</span>', 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'<span class="badge">{label} #{rank}{gain}</span>'
|
|
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"""<a class="repo-card" href="{url}" target="_blank" rel="noreferrer">
|
|
<div class="repo-top"><h3>{repo}</h3><span class="period-count">{x['periods']}/3</span></div>
|
|
<p>{desc}</p>
|
|
<div class="badges">{badges}</div>
|
|
<div class="lang">{lang}</div>
|
|
</a>"""
|
|
|
|
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)
|