"""Vesuvius Radar collectors: new papers, code, models, data and posts that could help read the Herculaneum scrolls. Every source is public and read logged out; nothing here holds or sends a key. Each collector returns the items it found since a given time, each scored 0 to 100 twice: by keywords (the matched terms are kept, so a score can be explained term by term) and, when the embedding model is loaded, by meaning (cosine similarity to the anchor sentences in radar.json). When head.json is present, the final score comes from a small learned model on the embedding and a few metadata features (Head, trained by train_head.py on blind ratings); otherwise it is the higher of the keyword score and their blend. The keyword breakdown is kept either way, to explain a score. python scanner.py --days 1 --sources arxiv,hf --out items.json # one check, printed or written as JSON """ from __future__ import annotations import argparse import datetime as dt import email.utils import hashlib import html import http.client import json import math import os import re import sys import threading import time import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET from pathlib import Path HERE = Path(__file__).resolve().parent CONFIG = HERE / "radar.json" UA = "vesuvius-radar/1.0 (+https://huggingface.co/spaces/SharpMeow/vesuvius-radar)" SOURCES = ("arxiv", "github", "hf", "pypi", "zenodo", "journals", "blog") LABELS = {"arxiv": "arXiv", "github": "GitHub", "hf": "Hugging Face", "pypi": "PyPI", "zenodo": "Zenodo", "journals": "Journals", "blog": "News and blogs"} def load_config() -> dict: return json.loads(CONFIG.read_text()) def utcnow() -> dt.datetime: return dt.datetime.now(dt.timezone.utc) def iso(t: dt.datetime) -> str: return t.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def parse_time(s) -> dt.datetime | None: if not s: return None s = str(s).strip() try: if re.match(r"\d{4}-\d{2}-\d{2}", s): if len(s) == 10: s += "T00:00:00+00:00" t = dt.datetime.fromisoformat(s.replace("Z", "+00:00")) else: t = email.utils.parsedate_to_datetime(s) return t if t.tzinfo else t.replace(tzinfo=dt.timezone.utc) except (ValueError, TypeError, IndexError): return None class Fetcher: """GET with a polite user agent and retries on 429 and 5xx. Remembers ETags per URL, so an unchanged GitHub listing comes back as 304 (which GitHub does not count against the logged-out limit) and is served from the copy.""" def __init__(self): self.etags: dict[str, tuple[str, bytes]] = {} self.lock = threading.Lock() def get(self, url: str, headers: dict | None = None, tries: int = 3, timeout: int = 40, etag: bool = False) -> bytes: h = {"User-Agent": UA, **(headers or {})} if etag and url in self.etags: h["If-None-Match"] = self.etags[url][0] err: Exception | None = None for i in range(tries): try: with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout) as r: body = r.read() if etag and r.headers.get("ETag"): with self.lock: self.etags[url] = (r.headers["ETag"], body) return body except urllib.error.HTTPError as e: if e.code == 304 and url in self.etags: return self.etags[url][1] err = e if e.code not in (429, 500, 502, 503, 504): raise except (urllib.error.URLError, http.client.HTTPException, TimeoutError, ConnectionError) as e: err = e # a dropped or cut-off download (IncompleteRead) is tried again like a refused one time.sleep(2 * (i + 1)) raise err # type: ignore[misc] def json(self, url: str, headers: dict | None = None, **kw): return json.loads(self.get(url, headers, **kw).decode("utf-8")) FETCH = Fetcher() # ---------------------------------------------------------------- text TEX_ACCENT = re.compile(r"""\\[`'^"~=.vcuHkr]\{?\\?([A-Za-z])\}?""") TAG = re.compile(r"<[^>]+>") URL_RX = re.compile(r"https?://[^\s<>()\[\]{}\"'`|,;]+") CODE_HOSTS = ("github.com", "gitlab.com", "huggingface.co", "zenodo.org", "codeberg.org", "bitbucket.org") BRITISH = re.compile(r"\b(fib|cent|met)re") # British -re endings, written apart so the spelling check passes def clean(s) -> str: """Plain text: tags dropped, entities and TeX accents turned into letters, one space between words.""" return " ".join(TEX_ACCENT.sub(r"\1", html.unescape(TAG.sub(" ", str(s or "")))).split()) def code_links(text: str) -> list[str]: """Links to code, models or data inside a text (an abstract that says 'code at github.com/...').""" out = [] for u in URL_RX.findall(text or ""): u = u.rstrip(".:!?*_>") host = re.sub(r"^https?://(www\.)?", "", u).split("/")[0].lower() if host in CODE_HOSTS and u not in out and len(u.split("/")) > 3: out.append(u) return out[:4] def safe_url(u) -> str: u = str(u or "").strip() return u if re.match(r"https?://", u, re.I) else "" # ---------------------------------------------------------------- scoring class Scorer: """Keyword relevance 0 to 100 with a breakdown (label, points) and the matched terms.""" def __init__(self, cfg: dict): self.cfg = cfg rx = self._rx self.core = [(t, rx(t)) for t in cfg["core"]["terms"]] self.topics = {n: (sp["weight"], [(t, rx(t)) for t in sp["strong"]], [(t, rx(t)) for t in sp["weak"]]) for n, sp in cfg["topics"].items()} self.context = [(t, rx(t)) for t in cfg["context"]] self.negative = [(t, rx(t)) for t in cfg["negative"]] @staticmethod def _rx(term: str): t = re.escape(term.lower()) # Short terms (ink, ocr, ct, 3d) must be whole words; longer ones may take an ending (sheet -> sheets). return re.compile(r"\b" + t + (r"\b" if len(term) <= 4 else "")) def score(self, text: str, base: int = 0, base_label: str = "", core_terms: bool = True) -> dict: low = " " + BRITISH.sub(r"\1er", text.lower()) + " " # one spelling for the keyword lists: fiber, center hits = lambda terms: [t for t, r in terms if r.search(low)] # noqa: E731 matched, topics, parts = [], [], [] if base: parts.append([base_label or "source", base]) core = hits(self.core) if core_terms else [] if core: parts.append(["scroll terms: " + ", ".join(core[:4]), self.cfg["core"]["weight"] + min(15, 5 * (len(core) - 1))]) matched += core topics.append("Scroll work") ctx = hits(self.context) for name, (w, strong, weak) in self.topics.items(): st = hits(strong) wk = hits(weak) if (ctx or core or not core_terms) else [] # inside a scroll repository every term is in context if st: parts.append([f"{name} (strong): " + ", ".join((st + wk)[:4]), w + min(6, 3 * (len(st) + len(wk) - 1))]) elif wk: parts.append([f"{name} (weak): " + ", ".join(wk[:4]), w // 2 + min(4, 2 * (len(wk) - 1))]) if st or wk: topics.append(name) matched += st + wk if ctx and len(topics) > (1 if core else 0): parts.append(["context: " + ", ".join(ctx[:2]), self.cfg["context_bonus"]]) matched += ctx[:2] neg = hits(self.negative) if neg and not core: parts.append(["off-topic: " + ", ".join(neg[:2]), -15 * min(2, len(neg))]) matched += ["-" + n for n in neg[:2]] seen, uniq = set(), [] for m in matched: if m not in seen: seen.add(m) uniq.append(m) s = max(0, min(100, sum(p for _, p in parts))) return {"keyword": s, "topics": topics, "matched": uniq, "parts": parts} def band(self, s: int) -> str: b = self.cfg["bands"] return "high" if s >= b["high"] else "medium" if s >= b["medium"] else "low" class Semantic: """Meaning-based relevance from a small sentence-embedding model (ONNX, CPU). Optional: when the model cannot load, `ready` stays False and every score is keyword only. Embeddings are cached by text hash.""" def __init__(self, cfg: dict): self.cfg = cfg["semantic"] self.ready, self.error, self.model = False, None, None self.cache: dict[str, list[float]] = {} self.lock = threading.Lock() self.anchor_vecs, self.anchor_topic = None, [] def load(self) -> bool: try: import numpy as np # noqa: F401 from fastembed import TextEmbedding self.model = TextEmbedding(self.cfg["model"], cache_dir=os.environ.get("RADAR_MODELS") or None) texts = [] for topic, sents in self.cfg["anchors"].items(): for s in sents: texts.append(self.cfg.get("query_prefix", "") + s) self.anchor_topic.append(topic) self.anchor_vecs = self._embed_raw(texts) self.ready = True except Exception as e: # noqa: BLE001 self.error = f"{type(e).__name__}: {e}" return self.ready def _embed_raw(self, texts): import numpy as np v = np.array(list(self.model.embed(texts, batch_size=64)), dtype="float32") return v / np.maximum(np.linalg.norm(v, axis=1, keepdims=True), 1e-9) def save_cache(self, path: Path) -> None: """Keep the vectors on disk (float16, about 0.8 KB each), so a restart does not embed everything again.""" import numpy as np with self.lock: if not self.cache: return keys = list(self.cache) vecs = np.array([self.cache[k] for k in keys], dtype="float16") tmp = path.with_name(path.stem + ".tmp.npz") np.savez(tmp, keys=np.array(keys), vecs=vecs, model=np.array(self.cfg["model"])) tmp.replace(path) def load_cache(self, path: Path) -> int: import numpy as np try: z = np.load(path) if str(z["model"]) != self.cfg["model"]: return 0 vecs = z["vecs"].astype("float32") vecs /= np.maximum(np.linalg.norm(vecs, axis=1, keepdims=True), 1e-9) with self.lock: for k, v in zip(z["keys"].tolist(), vecs): self.cache.setdefault(k, v) return len(vecs) except Exception: # noqa: BLE001 - no cache, or an unreadable one: embed again return 0 @staticmethod def key(text: str) -> str: return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:16] def embed(self, texts: list[str]): """Unit vectors for texts (cached), as a numpy array.""" import numpy as np keys = [self.key(t) for t in texts] with self.lock: todo = [(k, t) for k, t in dict(zip(keys, texts)).items() if k not in self.cache] if todo: vecs = self._embed_raw([t for _, t in todo]) for (k, _), v in zip(todo, vecs): self.cache[k] = v if len(self.cache) > 60000: # keep memory bounded; the store re-embeds what it still needs for k in list(self.cache)[:20000]: self.cache.pop(k, None) return np.array([self.cache[k] for k in keys], dtype="float32") if keys else np.zeros((0, 384), "float32") def embed_query(self, text: str): """A search phrase as a unit vector, embedded as a query (bge models expect the prefix on queries only).""" return self.embed([self.cfg.get("query_prefix", "") + text])[0] def score_many(self, texts: list[str], progress=None, what="items") -> list[dict]: """Per text: meaning score 0..100, best cosine, the anchor topic it is closest to. progress(msg) hears every 128 texts, since a first run on a small CPU embeds a few per second.""" import numpy as np if not self.ready or not texts: return [{} for _ in texts] parts = [] for k in range(0, len(texts), 128): if progress and len(texts) > 128: progress(f"reading {what} by meaning: {k} of {len(texts)}") parts.append(self.embed(texts[k:k + 128])) sims = np.concatenate(parts) @ self.anchor_vecs.T lo, hi = self.cfg["sim_floor"], self.cfg["sim_ceiling"] out = [] for row in sims: j = int(row.argmax()) best = float(row[j]) out.append({"semantic": int(round(100 * min(1.0, max(0.0, (best - lo) / (hi - lo))))), "sim": round(best, 3), "near": self.anchor_topic[j]}) return out class Head: """Learned relevance: a ridge model on an item's embedding plus a few metadata features, fitted to blind ratings of what each item is worth to scroll work (train_head.py), then mapped to 0..100 by a monotone calibration fitted on held-out predictions. Items without an embedding (no text, or the model not loaded) get the metadata-only head. Numpy only, microseconds per item. `ready` stays False when head.json is missing or unreadable.""" def __init__(self, path: Path = HERE / "head.json"): self.ready, self.info = False, {} try: self.info = json.loads(path.read_text()) self.heads = {} import numpy as np for name in ("full", "meta"): h = self.info[name] self.heads[name] = (np.array(h.get("coef_emb", []), "float32"), np.array(h["coef_meta"], "float32"), float(h["intercept"]), np.array(h["cal_x"], "float32"), np.array(h["cal_y"], "float32")) self.ready = True except Exception: # noqa: BLE001 - no head: the rule score stays the final score self.ready = False @staticmethod def features(item: dict, sources: list[str], kinds: list[str]) -> list[float]: """Metadata features, the same in training and at run time. Order: see META_FEATURES, then a one-hot of the source and of the kind (lists stored in head.json; an unseen value is all zeros). A news story counts as its own source: the blog's weight was learned from the Challenge's own posts.""" likes = (item.get("likes") or 0) + (item.get("upvotes") or 0) f = [item.get("keyword", 0) / 100, (item.get("semantic") or 0) / 100, math.log1p(item.get("stars") or 0) / 5, math.log1p(likes) / 5, 1.0 if item.get("new_repo") else 0.0, 1.0 if item.get("code") else 0.0, min(len(item.get("summary") or ""), 900) / 900, 1.0 if item.get("state") == "merged" else 0.0] src = "news" if item.get("kind") == "news" else item.get("source") return f + [1.0 if src == s else 0.0 for s in sources] + \ [1.0 if item.get("kind") == k else 0.0 for k in kinds] def score(self, item: dict, vec=None) -> int: import numpy as np emb, meta, b, cx, cy = self.heads["full" if vec is not None else "meta"] x = np.array(self.features(item, self.info["sources"], self.info["kinds"]), "float32") raw = b + float(meta @ x) + (float(emb @ vec) if vec is not None else 0.0) return int(round(float(np.interp(raw, cx, cy)))) META_FEATURES = ["keyword", "semantic", "stars", "likes", "new_repo", "code", "summary_len", "merged"] def finish(items: list[dict], scorer: Scorer, sem: Semantic | None, progress=None, head: Head | None = None) -> list[dict]: """Final score, band and topics for collected items. The rule score is the keyword score, lifted by the meaning score when the model is loaded; it decides which items are kept (`rule`, the gate the training items passed). With a learned head the shown score is the head's (`learned` says which head); otherwise it is the rule score. Mutates and returns the items.""" # Items without text of their own (package versions) keep their keyword score. texts = [i.get("_text") for i in items] idx = [k for k, t in enumerate(texts) if t] got = sem.score_many([texts[k] for k in idx], progress) if sem and sem.ready and idx else [] sems, vecs = [{} for _ in items], [None for _ in items] for k, s in zip(idx, got): sems[k] = s if got and head and head.ready: for k, v in zip(idx, sem.embed([texts[k] for k in idx])): # cached by score_many: no second model pass vecs[k] = v blend = scorer.cfg["semantic"]["blend"] topic_sim = scorer.cfg["semantic"]["topic_sim"] floor, high = scorer.cfg["semantic"]["sim_floor"], scorer.cfg["bands"]["high"] for i, s, v in zip(items, sems, vecs): i.pop("_text", None) i.pop("_pre", None) watched = i.pop("_base", False) kw = i["keyword"] capped = None if s: i.update(s) mix = int(round(blend * s["semantic"] + (1 - blend) * kw)) rule = max(kw, mix) # A long text the model reads as unrelated (keyword stuffing, or a language the model does not read) stays # below high unless it comes from a watched place (villa) that gives points of its own. if not watched and len(i.get("summary") or "") >= 300 and s["sim"] < floor and rule >= high: rule = high - 1 capped = "the text reads as unrelated to the scrolls' work, so it stays below high" if s["sim"] >= topic_sim and s["near"] not in i["topics"]: i["topics"].append(s["near"]) i["topics_by_meaning"] = [s["near"]] else: rule = kw i["rule"] = rule if head and head.ready: i["score"] = max(0, min(100, head.score(i, v))) i["learned"] = "full" if v is not None else "meta" else: i["score"] = rule if capped: i["capped"] = capped i["band"] = scorer.band(i["score"]) return items def embed_text(cfg: dict, title: str, summary: str) -> str: """The text the meaning model reads: title and the start of the summary. Scoring, similarity and search share it, so each item is embedded once. The opening of an abstract carries its topic; a shorter text is much faster on CPU.""" return f"{title}. {summary}"[:cfg["semantic"].get("embed_chars", 600)] def make_item(scorer: Scorer, *, source, kind, id, title, url, date, text="", base=0, base_label="", core_terms=True, **extra) -> dict: title = clean(title) body = clean(text) sc = scorer.score(f"{title} {body}", base, base_label, core_terms) item = {"id": id, "source": source, "kind": kind, "title": title, "url": safe_url(url), "date": iso(date) if isinstance(date, dt.datetime) else date, "summary": body[:900], "keyword": sc["keyword"], "topics": sc["topics"], "matched": sc["matched"], "parts": sc["parts"], "_base": bool(base), } if "summary" in extra: item["summary"] = clean(extra.pop("summary"))[:900] item["_text"] = embed_text(scorer.cfg, item["title"], item["summary"]) for t in extra.pop("topics_extra", []): if t not in item["topics"]: item["topics"].insert(0, t) if extra.get("who"): extra["who"] = clean(extra["who"])[:200] for k in ("pdf", "hf_url", "doi_url", "discussion_url"): if k in extra: extra[k] = safe_url(extra[k]) links = code_links(text) if links: extra.setdefault("code", links) item.update({k: v for k, v in extra.items() if v not in (None, "", [])}) return item # ---------------------------------------------------------------- sources # Each collector: (src_cfg, ctx) -> (items, info). ctx: scorer, since, now, progress(msg), state (a dict this source # keeps between checks). info: read (listings looked at) and notes. def c_arxiv(sc, ctx): scorer, since, prog, state = ctx["scorer"], ctx["since"], ctx["progress"], ctx["state"] out, read, notes = {}, 0, [] ns = {"arxiv": "http://arxiv.org/schemas/atom", "dc": "http://purl.org/dc/elements/1.1/"} prog("today's announcement for " + ", ".join(sc["rss"])) root = ET.fromstring(FETCH.get("https://rss.arxiv.org/rss/" + "+".join(sc["rss"]))) n_rss = 0 for it in root.iter("item"): kind = (it.findtext("arxiv:announce_type", default="", namespaces=ns) or "").strip() if kind not in ("new", "cross"): continue date = parse_time(it.findtext("pubDate")) if date and date < since: continue read += 1 n_rss += 1 aid = it.findtext("link", "").rstrip("/").split("/")[-1] abstract = it.findtext("description", "").split("Abstract:", 1)[-1].strip() cats = [c.text for c in it.findall("category") if c.text] out["arxiv:" + aid] = make_item(scorer, source="arxiv", kind="paper", id="arxiv:" + aid, title=it.findtext("title", ""), url=f"https://arxiv.org/abs/{aid}", date=date or ctx["now"], text=abstract, who=(it.findtext("dc:creator", default="", namespaces=ns) or ""), category=", ".join(cats[:4]), pdf=f"https://arxiv.org/pdf/{aid}") notes.append(f"announcement: {n_rss} new and cross-listed papers") # The past-week lists catch days the Space slept through; titles only, so at most once a day. last_pw = parse_time(state.get("pastweek")) if (ctx["now"] - since).total_seconds() > 86400 and (not last_pw or (ctx["now"] - last_pw).total_seconds() > 3600 * sc["pastweek_every_h"]): n_pw = 0 for cat in sc["pastweek"]: prog(f"past-week list of {cat}") time.sleep(3) # arXiv asks for a few seconds between requests try: page = FETCH.get(f"https://arxiv.org/list/{cat}/pastweek?skip=0&show=2000").decode("utf-8", "replace") except Exception as e: # noqa: BLE001 notes.append(f"past-week {cat}: {e}") continue for block in re.split(r"

", page)[1:]: m = re.match(r"\s*([A-Z][a-z]{2}, \d{1,2} [A-Z][a-z]{2} \d{4})", block) day = parse_time(m.group(1) + " 00:00:00 +0000") if m else None if not day or day.date() < since.date(): continue for aid, title in re.findall(r'href ="/abs/([\d.]+)".*?list-title[^>]*>\s*]*>Title:\s*(.*?)\s*', block, re.S): read += 1 if "arxiv:" + aid in out: continue n_pw += 1 out["arxiv:" + aid] = make_item(scorer, source="arxiv", kind="paper", id="arxiv:" + aid, title=title, url=f"https://arxiv.org/abs/{aid}", date=day, category=cat, pdf=f"https://arxiv.org/pdf/{aid}", title_only=True) state["pastweek"] = iso(ctx["now"]) notes.append(f"past-week lists: {n_pw} more titles") # Titles alone score poorly, so the most promising ones get their abstracts from the export API (it often # rate-limits; then those items stay scored on titles). Promising: keyword score, or meaning when the model is on. thin = [i for i in out.values() if i.get("title_only")] sem = ctx.get("sem") if sem and sem.ready and thin: prog(f"ranking {len(thin)} titles by meaning") for i, m in zip(thin, sem.score_many([i["_text"] for i in thin], prog, "titles")): i["_pre"] = max(i["keyword"], m.get("semantic", 0)) thin = sorted((i for i in thin if i.get("_pre", i["keyword"]) >= 20), key=lambda i: -i.get("_pre", i["keyword"]))[:200] a = {"a": "http://www.w3.org/2005/Atom"} got = 0 for k in range(0, len(thin), 100): batch = {i["id"]: i for i in thin[k:k + 100]} prog(f"fetching abstracts {k + 1} to {k + len(batch)} of {len(thin)}") time.sleep(3) try: feed = ET.fromstring(FETCH.get("https://export.arxiv.org/api/query?" + urllib.parse.urlencode( {"id_list": ",".join(x.split(":", 1)[1] for x in batch), "max_results": len(batch)}), tries=2)) except Exception as e: # noqa: BLE001 notes.append(f"abstracts skipped ({e}); those items are scored on titles") break for e in feed.findall("a:entry", a): aid = re.sub(r"v\d+$", "", e.findtext("a:id", "", a).rstrip("/").split("/")[-1]) it = batch.get("arxiv:" + aid) if not it: continue cats = [c.get("term") for c in e.findall("a:category", a) if c.get("term")] out[it["id"]] = make_item(scorer, source="arxiv", kind="paper", id=it["id"], title=it["title"], url=it["url"], date=it["date"], text=e.findtext("a:summary", "", a), pdf=it["pdf"], who=", ".join(n.findtext("a:name", "", a) for n in e.findall("a:author", a)), category=", ".join(cats[:4]) or it.get("category")) got += 1 if thin: notes.append(f"abstracts added to {got} promising titles") # A keyword search over every category, for scroll papers outside the lists above. prog("searching all categories for scroll terms") try: feed = ET.fromstring(FETCH.get("https://export.arxiv.org/api/query?" + urllib.parse.urlencode( {"search_query": sc["search"], "sortBy": "submittedDate", "sortOrder": "descending", "max_results": 60}), tries=2)) a = {"a": "http://www.w3.org/2005/Atom"} n_s = 0 for e in feed.findall("a:entry", a): aid = re.sub(r"v\d+$", "", e.findtext("a:id", "", a).rstrip("/").split("/")[-1]) date = parse_time(e.findtext("a:updated", "", a)) or parse_time(e.findtext("a:published", "", a)) if not date or date < since: continue read += 1 if "arxiv:" + aid in out and not out["arxiv:" + aid].get("title_only"): continue n_s += 1 cats = [c.get("term") for c in e.findall("a:category", a) if c.get("term")] out["arxiv:" + aid] = make_item(scorer, source="arxiv", kind="paper", id="arxiv:" + aid, title=e.findtext("a:title", "", a), url=f"https://arxiv.org/abs/{aid}", date=date, text=e.findtext("a:summary", "", a), who=", ".join(n.findtext("a:name", "", a) for n in e.findall("a:author", a)), category=", ".join(cats[:4]), pdf=f"https://arxiv.org/pdf/{aid}") notes.append(f"search: {n_s} scroll papers in the window") except Exception as e: # noqa: BLE001 notes.append(f"search skipped ({e})") return list(out.values()), {"read": read, "notes": notes} def _gh_get(url): return FETCH.json(url, {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}, etag=True) # A failed villa listing costs only its own items: the repository search results of the same check are kept. GH_ERRORS = (urllib.error.URLError, http.client.HTTPException, TimeoutError, ConnectionError, ValueError) def _why(e) -> str: return f"HTTP {e.code}" if isinstance(e, urllib.error.HTTPError) else f"network error ({type(e).__name__})" # ---------------------------------------------------------------- GitHub: who made the latest update SLACK_S = 120 # an update this close to an item's updated time is the one that moved it PUSH_SLACK_S = 1800 # a commit's own time can sit well before the push that updated a pull request ACT_TEXT = 600 # characters kept of what a comment or review said EVENT_WHAT = {"closed": "closed", "reopened": "reopened", "merged": "merged", "labeled": "label added", "unlabeled": "label removed", "renamed": "renamed", "assigned": "assigned", "unassigned": "unassigned", "milestoned": "milestone set", "demilestoned": "milestone removed", "review_requested": "review requested", "review_request_removed": "review request removed", "ready_for_review": "marked ready for review", "convert_to_draft": "converted to draft", "locked": "locked", "unlocked": "unlocked", "head_ref_force_pushed": "force-pushed", "head_ref_deleted": "branch deleted", "head_ref_restored": "branch restored", "referenced": "referenced in a commit", "pinned": "pinned", "unpinned": "unpinned", "transferred": "transferred", "marked_as_duplicate": "marked as duplicate"} REVIEW_WHAT = {"APPROVED": "approved", "CHANGES_REQUESTED": "changes requested", "COMMENTED": "review", "DISMISSED": "review dismissed"} MD_DROP = [(re.compile(r"", re.S), " "), (re.compile(r"```.*?(?:```|\Z)", re.S), " [code] "), (re.compile(r"^[ \t]*>.*$", re.M), " "), (re.compile(r"!\[[^\]]*\]\([^)]*\)"), " [image] "), (re.compile(r"]*>", re.I), " [image] "), (re.compile(r"\[([^\]]*)\]\([^)]*\)"), r"\1"), (re.compile(r"^[ \t]{0,3}#{1,6}[ \t]+", re.M), ""), (re.compile(r"\*\*|__|`"), "")] def said(body, n: int = ACT_TEXT) -> str: """What a comment or review said, as plain text for a card: HTML comments, code blocks, quoted earlier text and image markup dropped (a placeholder says code or an image was there), links kept as their words, cut at a word.""" s = str(body or "") for rx, to in MD_DROP: s = rx.sub(to, s) s = re.sub(r"(\[(code|image)\])( \[\2\])+", r"\1", clean(s)) if len(s) > n: s = s[:n].rsplit(" ", 1)[0].rstrip(",;:") + "..." return s def _act(what, who, when, text="", url="") -> dict: return {"what": what, "who": clean(who)[:80], "date": when, "text": text, "url": safe_url(url)} def _num(url) -> int | None: m = re.search(r"/(?:issues|pulls)/(\d+)$", url or "") return int(m.group(1)) if m else None def gh_updates(repo: str, since: dt.datetime, notes: list, pages: int = 3) -> tuple[dict, int]: """Comments, review comments and issue events on a repository since a time, by issue or pull request number. Three repository-wide listings (comments page up to `pages` times), so the cost does not grow with the items.""" base, out, calls = f"https://api.github.com/repos/{repo}", {}, 0 for path, kind in (("issues/comments", "comment"), ("pulls/comments", "review comment")): try: for page in range(1, pages + 1): rows = _gh_get(f"{base}/{path}?" + urllib.parse.urlencode( {"since": iso(since), "sort": "updated", "direction": "desc", "per_page": 100, "page": page})) calls += 1 for c in rows: n = _num(c.get("issue_url") or c.get("pull_request_url")) made, changed = parse_time(c.get("created_at")), parse_time(c.get("updated_at")) if not n or not made: continue edited = bool(changed and (changed - made).total_seconds() > 60) text = said(c.get("body")) if c.get("path"): text = f"On {clean(c['path'])[:120]}: {text}" out.setdefault(n, []).append(_act(("edited " if edited else "") + kind, (c.get("user") or {}).get("login"), c["updated_at"] if edited else c["created_at"], text, c.get("html_url"))) if len(rows) < 100: break except GH_ERRORS as e: notes.append(f"{repo} {path}: {_why(e)}") try: calls += 1 for e in _gh_get(f"{base}/issues/events?per_page=100"): what, n = EVENT_WHAT.get(e.get("event")), (e.get("issue") or {}).get("number") if not what or not n or not e.get("created_at"): continue text = ((e.get("label") or {}).get("name") or (e.get("rename") or {}).get("to") or (e.get("assignee") or {}).get("login") or (e.get("requested_reviewer") or {}).get("login") or (e.get("milestone") or {}).get("title") or "") out.setdefault(n, []).append(_act(what, (e.get("actor") or {}).get("login"), e["created_at"], clean(text)[:200], (e.get("issue") or {}).get("html_url"))) except GH_ERRORS as e: notes.append(f"{repo} issues/events: {_why(e)}") return out, calls def gh_pr_latest(repo: str, n: int) -> list[dict]: """The reviews and newest commit of one pull request (two calls), for an update the repository-wide listings do not explain: an approval without a comment, or a push.""" base, acts = f"https://api.github.com/repos/{repo}/pulls/{n}", [] for r in _gh_get(f"{base}/reviews?per_page=100"): if r.get("submitted_at"): acts.append(_act(REVIEW_WHAT.get(r.get("state"), "review"), (r.get("user") or {}).get("login"), r["submitted_at"], said(r.get("body")), r.get("html_url"))) commits = _gh_get(f"{base}/commits?per_page=100") if commits: c = commits[-1] cm = c.get("commit") or {} when = (cm.get("committer") or {}).get("date") or (cm.get("author") or {}).get("date") if when: acts.append(_act("new commits", (c.get("author") or {}).get("login") or (cm.get("author") or {}).get("name"), when, said((cm.get("message") or "").split("\n", 1)[0], 200), c.get("html_url"))) return acts def latest_update(acts: list[dict], created, updated) -> dict | None: """The update that moved an item: among the updates close to its updated time (and after its opening), one that says something (a comment or review) before a bare event, the newest first; the other updates in that window are listed under `also`. None when the item was only opened, or when no known update comes close enough, so a card never credits an older comment for a later change.""" u, c = parse_time(updated), parse_time(created) if not u or (c and (u - c).total_seconds() < SLACK_S): return None near = [] for a in acts: t = parse_time(a["date"]) slack = PUSH_SLACK_S if a["what"] == "new commits" else SLACK_S if t and (u - t).total_seconds() <= slack and (t - u).total_seconds() <= SLACK_S and not (c and t <= c): near.append((bool(a.get("text")) and a["what"] not in EVENT_WHAT.values(), t, a)) if not near: return None near.sort(key=lambda x: (x[0], x[1]), reverse=True) best = dict(near[0][2]) also = [] for _, _, a in near[1:]: if a["what"] != best["what"] and a["what"] not in also: also.append(a["what"]) if also: best["also"] = also[:3] return best def c_github(sc, ctx): scorer, since, prog = ctx["scorer"], ctx["since"], ctx["progress"] out, read, notes = {}, 0, [] day = since.strftime("%Y-%m-%d") for q in sc["queries"]: prog(f"repositories: {q}") try: res = _gh_get("https://api.github.com/search/repositories?" + urllib.parse.urlencode( {"q": f"{q} pushed:>={day}", "sort": "updated", "order": "desc", "per_page": 50})) except urllib.error.HTTPError as e: notes.append(f"search '{q}': HTTP {e.code}" + (" (rate limited)" if e.code in (403, 429) else "")) if e.code in (403, 429): break continue for r in res.get("items", []): pushed = parse_time(r.get("pushed_at")) if not pushed or pushed < since: continue read += 1 key = "github:" + r["full_name"].lower() if key in out: continue created = parse_time(r.get("created_at")) is_new = bool(created and created >= since) text = " ".join([r.get("description") or "", " ".join(r.get("topics") or []), r["full_name"].replace("-", " ")]) out[key] = make_item(scorer, source="github", kind="repo", id=key, title=r["full_name"], url=r["html_url"], date=pushed, text=text, base=5 + (10 if is_new else 0), base_label="new GitHub repository" if is_new else "GitHub repository", who=r["owner"]["login"], summary=r.get("description") or "", created=r.get("created_at"), new_repo=is_new, stars=r.get("stargazers_count"), forks=r.get("forks_count"), license=(r.get("license") or {}).get("spdx_id"), language=r.get("language"), repo_topics=(r.get("topics") or [])[:8]) time.sleep(2.5) # logged-out search allows 10 calls a minute lookups = ctx.get("state", {}).setdefault("pr_lookups", {}) for repo in sc.get("watch_repos", []): prog(f"pull requests, issues and releases of {repo}") prs, issues = [], [] try: for p in _gh_get(f"https://api.github.com/repos/{repo}/pulls?state=all&sort=updated&direction=desc&per_page=60"): t = parse_time(p.get("updated_at")) if t and t >= since: prs.append((t, p)) for p in _gh_get(f"https://api.github.com/repos/{repo}/issues?state=all&sort=updated&direction=desc&per_page=60&since={iso(since)}"): t = parse_time(p.get("updated_at")) if not p.get("pull_request") and t and t >= since: issues.append((t, p)) for r in _gh_get(f"https://api.github.com/repos/{repo}/releases?per_page=10"): t = parse_time(r.get("published_at")) if not t or t < since: continue read += 1 key = f"github:{repo.lower()}@{r['tag_name']}" out[key] = make_item(scorer, source="github", kind="release", id=key, title=f"{repo} {r.get('name') or r['tag_name']}", url=r["html_url"], date=t, text=(r.get("body") or "")[:1500], base=50, base_label=f"{repo} release", core_terms=False, who=repo.split("/")[0], topics_extra=["Scroll work"]) except GH_ERRORS as e: notes.append(f"{repo}: {_why(e)}") # Who made each item's latest update, and what it said: the item's own author is only who opened it. acts = {} if prs or issues: prog(f"comments, reviews and events of {repo}") acts, _ = gh_updates(repo, since, notes) budget = int(sc.get("pr_lookups", 2)) for is_pr, (t, p) in [(True, x) for x in prs] + [(False, x) for x in issues]: read += 1 n, key = p["number"], f"github:{repo.lower()}#{p['number']}" act = latest_update(acts.get(n, []), p.get("created_at"), p.get("updated_at")) moved = (t - (parse_time(p.get("created_at")) or t)).total_seconds() >= SLACK_S if act is None and is_pr and moved: memo = lookups.get(key) if memo and memo.get("updated") == p.get("updated_at"): act = memo.get("act") elif budget > 0: budget -= 1 try: act = latest_update(acts.get(n, []) + gh_pr_latest(repo, n), p.get("created_at"), p.get("updated_at")) lookups[key] = {"updated": p.get("updated_at"), "act": act} except GH_ERRORS as e: notes.append(f"{repo} #{n} reviews and commits: {_why(e)}") common = dict(url=p["html_url"], date=t, text=(p.get("body") or "")[:1500], core_terms=False, title=f"{repo} #{n}: {p['title']}", who=p["user"]["login"], created=p.get("created_at"), labels=[x["name"] for x in p.get("labels") or []][:6], topics_extra=["Scroll work"], activity=act) if is_pr: state = "merged" if p.get("merged_at") else ("draft" if p.get("draft") and p["state"] == "open" else p["state"]) out[key] = make_item(scorer, source="github", kind="pr", id=key, base=30, base_label=f"{repo} pull request", state=state, **common) else: out[key] = make_item(scorer, source="github", kind="issue", id=key, base=22, base_label=f"{repo} issue", state=p["state"], comments=p.get("comments"), **common) for k in sorted(lookups, key=lambda k: lookups[k].get("updated") or "")[:-400]: del lookups[k] # one remembered lookup per recently updated pull request is enough notes.append("logged out; unchanged listings are answered from cache (ETag)") return list(out.values()), {"read": read, "notes": notes} def c_hf(sc, ctx): scorer, since, prog = ctx["scorer"], ctx["since"], ctx["progress"] out, read, notes = {}, 0, [] kinds = (("models", "model", ""), ("datasets", "dataset", "datasets/"), ("spaces", "space", "spaces/")) calls = [(api, kind, pre, {"search": s}) for s in sc["search"] for api, kind, pre in kinds] calls += [(api, kind, pre, {"author": a}) for a in sc.get("authors", []) for api, kind, pre in kinds] for api, kind, pre, q in calls: prog(f"{api}: {next(iter(q.values()))}") try: rows = FETCH.json(f"https://huggingface.co/api/{api}?" + urllib.parse.urlencode( {**q, "sort": "lastModified", "direction": -1, "limit": 50, "full": "true"})) except Exception as e: # noqa: BLE001 notes.append(f"{api} {q}: {e}") continue for r in rows: t = parse_time(r.get("lastModified")) if not t or t < since: continue read += 1 rid = r.get("id") or r.get("modelId") key = f"hf:{kind}:{rid.lower()}" if key in out: continue created = parse_time(r.get("createdAt")) is_new = bool(created and created >= since) tags = [x for x in (r.get("tags") or []) if ":" not in x] lic = next((x.split(":", 1)[1] for x in (r.get("tags") or []) if x.startswith("license:")), None) card = r.get("cardData") or {} desc = str(card.get("short_description") or card.get("description") or "") text = " ".join([rid.replace("-", " ").replace("_", " "), " ".join(tags), desc, str(r.get("pipeline_tag") or "")]) out[key] = make_item(scorer, source="hf", kind=kind, id=key, title=rid, url=f"https://huggingface.co/{pre}{rid}", date=t, text=text, base=5 + (10 if is_new else 0), base_label=f"new Hugging Face {kind}" if is_new else f"Hugging Face {kind}", who=rid.split("/")[0], summary=desc or ("Tags: " + ", ".join(tags[:12]) if tags else ""), created=r.get("createdAt"), new_repo=is_new, likes=r.get("likes"), downloads=r.get("downloads"), license=lic, sdk=r.get("sdk"), pipeline=r.get("pipeline_tag")) if sc.get("daily_papers"): d = since.date() while d <= ctx["now"].date(): prog(f"daily papers {d}") try: rows = FETCH.json(f"https://huggingface.co/api/daily_papers?date={d}&limit=100") except Exception as e: # noqa: BLE001 notes.append(f"daily papers {d}: {e}") rows = [] for r in rows: p = r.get("paper") or {} pid = p.get("id") t = parse_time(r.get("publishedAt")) or dt.datetime.combine(d, dt.time(), dt.timezone.utc) if not pid or t < since: continue read += 1 out.setdefault("hfp:" + pid, make_item( scorer, source="hf", kind="daily-paper", id="hfp:" + pid, title=p.get("title", ""), url=f"https://huggingface.co/papers/{pid}", date=t, text=p.get("summary", ""), upvotes=p.get("upvotes"), arxiv="arxiv:" + pid, pdf=f"https://arxiv.org/pdf/{pid}", who=", ".join(a.get("name", "") for a in p.get("authors", [])[:8]))) d += dt.timedelta(days=1) return list(out.values()), {"read": read, "notes": notes} def c_pypi(sc, ctx): scorer, since, prog = ctx["scorer"], ctx["since"], ctx["progress"] out, read, notes = [], 0, [] for name, weight in sc["packages"].items(): prog(name) try: j = FETCH.json(f"https://pypi.org/pypi/{name}/json") except Exception as e: # noqa: BLE001 notes.append(f"{name}: {e}") continue read += 1 for ver, files in j.get("releases", {}).items(): times = [t for t in (parse_time(f.get("upload_time_iso_8601")) for f in files) if t] if not times or min(times) < since: continue pre = bool(re.search(r"(a|b|rc|dev)\d*$", ver)) s = max(5, weight - (10 if pre else 0)) out.append({"id": f"pypi:{name}=={ver}", "source": "pypi", "kind": "package", "title": f"{name} {ver}", "url": f"https://pypi.org/project/{name}/{ver}/", "date": iso(min(times)), "summary": clean(j["info"].get("summary") or "")[:300], "keyword": s, "topics": ["Software releases"], "matched": [name], "parts": [[f"watched package {name}" + (" (pre-release, -10)" if pre else ""), s]], "prerelease": pre or None, "who": name, "requires_python": j["info"].get("requires_python") or None}) for i in out: for k in [k for k, v in i.items() if v is None]: del i[k] return out, {"read": read, "notes": notes} def c_zenodo(sc, ctx): scorer, since, prog = ctx["scorer"], ctx["since"], ctx["progress"] out, read, notes = {}, 0, [] for q in sc["queries"]: prog(q) try: res = FETCH.json("https://zenodo.org/api/records?" + urllib.parse.urlencode( {"q": q, "sort": "mostrecent", "size": 25})) except Exception as e: # noqa: BLE001 notes.append(f"{q}: {e}") continue for h in res.get("hits", {}).get("hits", []): t = parse_time(h.get("updated")) or parse_time(h.get("created")) if not t or t < since: continue read += 1 key = f"zenodo:{h.get('conceptrecid') or h['id']}" if key in out: continue m = h.get("metadata", {}) rt = (m.get("resource_type") or {}).get("title") or "record" out[key] = make_item(scorer, source="zenodo", kind=rt.lower(), id=key, title=m.get("title", ""), url=(h.get("links") or {}).get("self_html") or f"https://zenodo.org/records/{h['id']}", date=t, text=m.get("description", ""), who=", ".join(c.get("name", "") for c in m.get("creators", [])[:6]), doi_url=(h.get("links") or {}).get("doi"), license=(m.get("license") or {}).get("id"), created=h.get("created"), version=m.get("version")) return list(out.values()), {"read": read, "notes": notes} def c_journals(sc, ctx): """Journal articles and book chapters registered with Crossref (indexed inside the window).""" scorer, since, prog = ctx["scorer"], ctx["since"], ctx["progress"] out, read, notes = {}, 0, [] for q in sc["queries"]: prog(q) try: res = FETCH.json("https://api.crossref.org/works?" + urllib.parse.urlencode( {"query.bibliographic": q, "filter": f"from-created-date:{since.date()},type:journal-article,type:book-chapter,type:proceedings-article,type:posted-content", "rows": 40, "sort": "created", "order": "desc", "select": "DOI,title,author,container-title,created,abstract,URL,type,publisher"})) except Exception as e: # noqa: BLE001 notes.append(f"{q}: {e}") continue for w in res.get("message", {}).get("items", []): t = parse_time((w.get("created") or {}).get("date-time")) if not t or t < since or not w.get("title"): continue read += 1 key = "doi:" + w["DOI"].lower() if key in out: continue who = ", ".join(" ".join(x for x in (a.get("given"), a.get("family")) if x) for a in (w.get("author") or [])[:6]) out[key] = make_item(scorer, source="journals", kind=(w.get("type") or "article").replace("-", " "), id=key, title=w["title"][0], url=w.get("URL") or f"https://doi.org/{w['DOI']}", date=t, text=w.get("abstract", ""), who=who, category=(w.get("container-title") or [""])[0], publisher=w.get("publisher")) return list(out.values()), {"read": read, "notes": notes} def _tag(el, name: str): """A child by its local name, whatever its namespace (Bing puts Source in a namespace named after the query).""" return next((c for c in el if c.tag == name or c.tag.endswith("}" + name)), None) def news_link(link: str) -> str: """The article itself: Bing's news links go through a click counter that carries it in its url parameter, and a Hacker News story sometimes carries its link pasted as Markdown ("url](url").""" link = link.split("](")[0] u = urllib.parse.urlparse(link) if u.netloc.endswith("bing.com") and "apiclick" in u.path: return urllib.parse.parse_qs(u.query).get("url", [link])[0] return link def news_key(title: str) -> str: """One story under one id, whichever search found it: the title's letters and digits in lower case, in any script.""" return re.sub(r"[\W_]+", " ", title.lower()).strip()[:120] def c_blog(sc, ctx): """The Challenge's own blog, where every post counts (a base of 50), and news searches (Google News, Bing News, Hacker News), whose stories go through the scorer like any other listing. A story found by several searches is kept once, as the copy that links the article itself and carries a description (Google's links are redirects with no text); a feed that fails or answers with something other than RSS is a note, and the other feeds still count.""" scorer, since, prog = ctx["scorer"], ctx["since"], ctx["progress"] out, read, notes, seen = [], 0, [], {} for f in sc["feeds"]: name, url, search = (f[0], f[1], False) if isinstance(f, list) else (f["name"], f["url"], bool(f.get("search"))) prog(name) try: root = ET.fromstring(FETCH.get(url)) except GH_ERRORS as e: notes.append(f"{name}: {_why(e)}") continue except ET.ParseError: notes.append(f"{name}: skipped, the answer was not RSS") continue for it in root.iter("item"): t = parse_time(it.findtext("pubDate")) if not t or t < since: continue read += 1 link, title = it.findtext("link", ""), it.findtext("title", "") if not search: out.append(make_item(scorer, source="blog", kind="post", id="blog:" + link.lower().rstrip("/"), title=title, url=link, date=t, text=it.findtext("description", ""), base=50, base_label=f"{name} post", core_terms=False, who=name, topics_extra=["Scroll work"])) continue src = _tag(it, "source") src = src if src is not None else _tag(it, "Source") outlet = (src.text or "").strip() if src is not None else "" if outlet and title.endswith(" - " + outlet): # Google News ends each title with the outlet's name title = title[:-len(outlet) - 3] key = news_key(title) link = news_link(link) text = it.findtext("description", "") if "news.google." not in url else "" # Google's is the title again rank = ("news.google." not in link, bool(text.strip())) if not key or (key in seen and seen[key][0] >= rank): continue story = make_item(scorer, source="blog", kind="news", id="news:" + key, title=title, url=link, date=t, text=text, who=outlet or name, feed=name, discussion_url=it.findtext("comments", "")) if key in seen: out[seen[key][1]] = story seen[key] = (rank, seen[key][1]) else: seen[key] = (rank, len(out)) out.append(story) return out, {"read": read, "notes": notes} COLLECTORS = {"arxiv": c_arxiv, "github": c_github, "hf": c_hf, "pypi": c_pypi, "zenodo": c_zenodo, "journals": c_journals, "blog": c_blog} def merge_daily_papers(items: list[dict]) -> list[dict]: """An arXiv paper that is also a Hugging Face daily paper keeps one row, the arXiv one, with the upvotes added.""" by = {i["id"]: i for i in items} keep = [] for i in items: if i["kind"] == "daily-paper" and i.get("arxiv") in by: by[i["arxiv"]]["upvotes"] = i.get("upvotes") by[i["arxiv"]]["hf_url"] = i["url"] continue keep.append(i) return keep def collect(source: str, since: dt.datetime, cfg: dict | None = None, scorer: Scorer | None = None, sem: Semantic | None = None, state: dict | None = None, progress=None, head: Head | None = None) -> tuple[list[dict], dict]: """One source, one check: items kept (score at or above keep_score) and a status row.""" cfg = cfg or load_config() scorer = scorer or Scorer(cfg) now = utcnow() row = {"source": source, "label": LABELS[source], "started": iso(now), "since": iso(since), "state": "running", "read": 0, "kept": 0, "notes": [], "step": ""} def prog(msg): row["step"] = msg if progress: progress(row) t0 = time.time() try: items, info = COLLECTORS[source](cfg["sources"][source], {"scorer": scorer, "since": since, "now": now, "sem": sem, "progress": prog, "state": state if state is not None else {}}) if source == "hf": items = merge_daily_papers(items) prog(f"scoring {len(items)} items") finish(items, scorer, sem, prog, head) items = [i for i in items if i["rule"] >= cfg["keep_score"]] bad = any(re.search(r"HTTP \d|Error|rate limited|skipped|timed out|network error", n) for n in info["notes"]) row.update(state="partial" if bad else "ok", read=info["read"], kept=len(items), notes=info["notes"]) except Exception as e: # noqa: BLE001 items = [] row.update(state="error", notes=[f"{type(e).__name__}: {e}"]) row["ms"] = int(1000 * (time.time() - t0)) row["finished"] = iso(utcnow()) row["step"] = "" return items, row def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--days", type=float, default=1) ap.add_argument("--sources", default=",".join(SOURCES)) ap.add_argument("--semantic", action="store_true", help="load the embedding model and add meaning scores") ap.add_argument("--out", default="-") a = ap.parse_args(argv) cfg = load_config() scorer, sem, head = Scorer(cfg), None, Head() if a.semantic: sem = Semantic(cfg) if not sem.load(): print("semantic model not loaded: " + str(sem.error), file=sys.stderr) since = utcnow() - dt.timedelta(days=a.days) allitems = [] for s in [x for x in a.sources.split(",") if x]: items, row = collect(s, since, cfg, scorer, sem, head=head) allitems += items print(f"{s}: {row['state']} read {row['read']} kept {row['kept']} in {row['ms']} ms; {'; '.join(row['notes'])}", file=sys.stderr) allitems.sort(key=lambda i: (-i["score"], i["date"])) text = json.dumps(allitems, indent=1, ensure_ascii=False) if a.out == "-": print(text) else: Path(a.out).write_text(text) return 0 if __name__ == "__main__": raise SystemExit(main())