Tech signals
Find the themes that many Hacker News stories of the last days share, read each theme's stories, and make one grounded prediction per theme: a short claim, why, how confident, and the facts from the pages it rests on.
When to use it: You want the themes many stories share this week, with a grounded prediction for each.
| Uses | a shell session (exec, files), extract over each theme's pages |
|---|---|
| Needs | shell sessions and a model on the API (no model key of your own) |
| Site | Hacker News' official search API; the runner uses a stand-in set of stories |
| Output | output/predictions.md, output/result.json (themes, stories, predictions and their evidence) |
| Languages | Node and Python |
How it works:
- On the machine:
signals.pycollects the window's stories with at leastMIN_POINTSpoints through HN's search API, page by page, and finds the themes: a term two or more stories share (terms with the very same stories are one theme, like "rust kernel linux"), ranked by the stories' points and comments. A story belongs to one theme only. One script, so the Node and Python versions do the same thing. - In a sandboxed browser: for each theme, one
extractcall reads up to 3 of its stories' pages and returns a prediction for the next 6 to 12 months with 2 to 4 facts, each citing its page. Predictions come from what the pages say, not from headlines alone.
The code
The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.
/** * Tech signals: find the themes many Hacker News stories of the last days share, read each theme's stories, and make * one grounded prediction per theme, with the facts from the pages it rests on. * * npx tsx node/index.ts (BOXLINE_API_KEY; a plan with shell sessions; WINDOW_HOURS, MIN_POINTS, TOP) * * signals.py does the collecting and clustering in the session's shell (HN's official search API); then one extract * call per theme reads up to 3 of its stories' pages. Writes output/result.json and output/predictions.md. */import { mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { Boxline } from "@boxline/sdk";const here = dirname(fileURLToPath(import.meta.url));const out = process.env.OUTPUT_DIR ?? "output";const env = { HN_SEARCH_URL: process.env.HN_SEARCH_URL ?? "https://hn.algolia.com/api/v1/search_by_date", WINDOW_HOURS: process.env.WINDOW_HOURS ?? "48", MIN_POINTS: process.env.MIN_POINTS ?? "50", TOP: process.env.TOP ?? "3", SPECIFICITY: process.env.SPECIFICITY ?? "4.5",};const bx = new Boxline();type Story = { id: string; title: string; url: string; points: number; comments: number };type Theme = { label: string; score: number; stories: Story[] };type Prediction = { claim: string; rationale: string; confidence: "low" | "medium" | "high"; evidence: { url: string; fact: string }[] };const session = await bx.sessions.create({ browser: false, shell: true, setup: ["pip install -q wordfreq"], timeout: 600, idleTimeout: 300, userMetadata: { example: "tech-signals" } });console.log(`Session: ${session.id}`);let signals: { collected: number; windowHours: number; themes: Theme[] };try { await session.files.write("signals.py", readFileSync(join(here, "../signals.py"))); const r = await session.exec("python signals.py", { env, timeoutMs: 180_000 }); if (r.exitCode !== 0) throw new Error(`signals.py failed: ${r.stderr.trim()}`); console.log(r.stdout.trim()); signals = JSON.parse(await session.files.readText("signals.json"));} finally { await session.stop(); // the predictions below need no machine of ours}if (!signals.themes.length) throw new Error("no theme is shared by two stories in this window; widen WINDOW_HOURS or lower MIN_POINTS");// One prediction per theme, from what its stories' pages say (not just their titles).const predictions: (Prediction & { theme: string; stories: Story[]; pages: unknown[] })[] = [];let modelUsd = 0;for (const theme of signals.themes) { const stories = theme.stories.slice(0, 3); const r = await bx.extract<Prediction>({ urls: stories.map((s) => s.url), prompt: `These pages are recent stories that share one theme: "${theme.label}". Make one prediction about where this is going in ` + `the next 6 to 12 months. claim: at most 30 words. rationale: why, from what the pages say. confidence: low, medium or high. ` + `evidence: 2 to 4 facts as the pages state them, each with the URL of its page (one of the pages given).`, schema: { type: "object", properties: { claim: { type: "string" }, rationale: { type: "string" }, confidence: { type: "string", enum: ["low", "medium", "high"] }, evidence: { type: "array", items: { type: "object", properties: { url: { type: "string" }, fact: { type: "string" } }, required: ["url", "fact"] } }, }, required: ["claim", "rationale", "confidence", "evidence"], }, }); modelUsd += r.usage.costUsd; predictions.push({ theme: theme.label, stories, pages: r.pages, ...r.data }); console.log(`\n${theme.label} (${theme.stories.length} stories, score ${theme.score})\n → ${r.data.claim} [${r.data.confidence}]\n ${r.data.rationale}`); for (const e of r.data.evidence) console.log(` · ${e.fact} (${e.url})`);}const md = [ `# Tech signals: the last ${signals.windowHours} hours`, "", `${signals.collected} Hacker News stories; the themes several of them share, and where each may be going.`, "", ...predictions.flatMap((p) => [ `## ${p.theme}`, "", `**${p.claim}** (confidence: ${p.confidence})`, "", p.rationale, "", ...p.evidence.map((e) => `- ${e.fact} ([source](${e.url}))`), "", `Stories: ${p.stories.map((s) => `[${s.title}](${s.url}) (${s.points} points)`).join(" · ")}`, "", ]),].join("\n");mkdirSync(out, { recursive: true });writeFileSync(join(out, "predictions.md"), md);writeFileSync(join(out, "result.json"), JSON.stringify({ collected: signals.collected, windowHours: signals.windowHours, themes: signals.themes, predictions, usage: { modelUsd } }, null, 2));console.log(`\nWrote ${join(out, "predictions.md")} ($${modelUsd.toFixed(4)} of model use)`);"""Tech signals: find the themes many Hacker News stories of the last days share, read each theme's stories, and makeone grounded prediction per theme, with the facts from the pages it rests on. python python/main.py (BOXLINE_API_KEY; a plan with shell sessions; WINDOW_HOURS, MIN_POINTS, TOP)signals.py does the collecting and clustering in the session's shell (HN's official search API); then one extractcall per theme reads up to 3 of its stories' pages. Writes output/result.json and output/predictions.md."""import jsonimport osfrom pathlib import Pathfrom boxline import Boxlinehere = Path(__file__).resolve().parentout = Path(os.environ.get("OUTPUT_DIR", "output"))env = { "HN_SEARCH_URL": os.environ.get("HN_SEARCH_URL", "https://hn.algolia.com/api/v1/search_by_date"), "WINDOW_HOURS": os.environ.get("WINDOW_HOURS", "48"), "MIN_POINTS": os.environ.get("MIN_POINTS", "50"), "TOP": os.environ.get("TOP", "3"), "SPECIFICITY": os.environ.get("SPECIFICITY", "4.5"),}bx = Boxline()with bx.sessions.create(browser=False, shell=True, setup=["pip install -q wordfreq"], timeout=600, idle_timeout=300, user_metadata={"example": "tech-signals"}) as session: print(f"Session: {session.id}", flush=True) session.files.write("signals.py", (here.parent / "signals.py").read_bytes()) r = session.exec("python signals.py", env=env, timeout_ms=180_000) if r["exitCode"] != 0: raise SystemExit(f"signals.py failed: {r['stderr'].strip()}") print(r["stdout"].strip()) signals = json.loads(session.files.read_text("signals.json"))# the session is stopped here: the predictions below need no machine of oursif not signals["themes"]: raise SystemExit("no theme is shared by two stories in this window; widen WINDOW_HOURS or lower MIN_POINTS")# One prediction per theme, from what its stories' pages say (not just their titles).predictions, model_usd = [], 0.0for theme in signals["themes"]: stories = theme["stories"][:3] r = bx.extract( urls=[s["url"] for s in stories], prompt=( f'These pages are recent stories that share one theme: "{theme["label"]}". Make one prediction about where this is going in ' "the next 6 to 12 months. claim: at most 30 words. rationale: why, from what the pages say. confidence: low, medium or high. " "evidence: 2 to 4 facts as the pages state them, each with the URL of its page (one of the pages given)." ), schema={ "type": "object", "properties": { "claim": {"type": "string"}, "rationale": {"type": "string"}, "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, "evidence": {"type": "array", "items": {"type": "object", "properties": {"url": {"type": "string"}, "fact": {"type": "string"}}, "required": ["url", "fact"]}}, }, "required": ["claim", "rationale", "confidence", "evidence"], }, ) model_usd += r["usage"]["costUsd"] p = r["data"] predictions.append({"theme": theme["label"], "stories": stories, "pages": r["pages"], **p}) print(f"\n{theme['label']} ({len(theme['stories'])} stories, score {theme['score']})\n → {p['claim']} [{p['confidence']}]\n {p['rationale']}") for e in p["evidence"]: print(f" · {e['fact']} ({e['url']})")md = [f"# Tech signals: the last {signals['windowHours']:g} hours", "", f"{signals['collected']} Hacker News stories; the themes several of them share, and where each may be going.", ""]for p in predictions: md += [f"## {p['theme']}", "", f"**{p['claim']}** (confidence: {p['confidence']})", "", p["rationale"], ""] md += [f"- {e['fact']} ([source]({e['url']}))" for e in p["evidence"]] + [""] md += ["Stories: " + " · ".join(f"[{s['title']}]({s['url']}) ({s['points']} points)" for s in p["stories"]), ""]out.mkdir(parents=True, exist_ok=True)(out / "predictions.md").write_text("\n".join(md))result = {"collected": signals["collected"], "windowHours": signals["windowHours"], "themes": signals["themes"], "predictions": predictions, "usage": {"modelUsd": model_usd}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))print(f"\nWrote {out / 'predictions.md'} (${model_usd:.4f} of model use)")"""Runs in the session's shell: collects Hacker News stories of the last WINDOW_HOURS with at least MIN_POINTS points(HN's official search API, page by page), finds the themes several stories share, and writes /workspace/signals.json:the top TOP themes, each with its stories, strongest first. Inputs: HN_SEARCH_URL, WINDOW_HOURS, MIN_POINTS, TOP, andSPECIFICITY: a theme's term must be rarer than this in everyday English (wordfreq's Zipf scale: "data" 5.6, "kernel"3.6, "webgpu" 0), so common words do not make themes. The session's setup installs wordfreq."""import jsonimport mathimport osimport reimport timeimport urllib.parseimport urllib.requestbase = os.environ.get("HN_SEARCH_URL", "https://hn.algolia.com/api/v1/search_by_date")window = float(os.environ.get("WINDOW_HOURS", "48"))min_points = int(os.environ.get("MIN_POINTS", "50"))top = int(os.environ.get("TOP", "3"))specificity = float(os.environ.get("SPECIFICITY", "4.5"))try: from wordfreq import zipf_frequencyexcept ImportError: # without wordfreq only the stop words below keep common words out def zipf_frequency(word, lang): return 0.0since = int(time.time() - window * 3600)STOP = set("""a about after all also an and any are as at be been but by can could did do does for from had has have howi if in into is it its just like make more most my new no not now of on one or our out over show ask tell hn so some thanthat the their them then there these they this to too up us use using via was we what when where which who why will withwould you your yours vs year years day days week first last get got way ways all only own still back every""".split())stories, page = [], 0while page < 10: q = urllib.parse.urlencode({"tags": "story", "numericFilters": f"created_at_i>{since},points>={min_points}", "hitsPerPage": 100, "page": page}) req = urllib.request.Request(f"{base}?{q}", headers={"user-agent": "tech-signals (a Boxline example)"}) with urllib.request.urlopen(req, timeout=30) as r: data = json.load(r) for h in data.get("hits", []): if h.get("title"): stories.append({"id": h["objectID"], "title": h["title"], "url": h.get("url") or f"https://news.ycombinator.com/item?id={h['objectID']}", "points": h.get("points") or 0, "comments": h.get("num_comments") or 0, "at": h.get("created_at_i")}) page += 1 if page >= data.get("nbPages", 1): breakdef norm(w): w = w.strip(".-") return w[:-1] if w.endswith("s") and not w.endswith("ss") and len(w) > 4 else w # agents → agentdef terms(title): words = {norm(w) for w in re.findall(r"[a-z][a-z0-9+#.-]{2,}", title.lower())} return {w for w in words if len(w) > 2 and w not in STOP and zipf_frequency(w, "en") < specificity}# A theme: a term at least two stories share. Terms with the very same stories are one theme ("rust" + "kernel").by_term = {}for s in stories: for t in terms(s["title"]): by_term.setdefault(t, set()).add(s["id"])groups = {}for t, ids in by_term.items(): if len(ids) >= 2: groups.setdefault(frozenset(ids), []).append(t)weight = {s["id"]: math.log1p(s["points"]) + 0.5 * math.log1p(s["comments"]) for s in stories}ranked = sorted(groups.items(), key=lambda g: -sum(weight[i] for i in g[0]))themes, used = [], set()for ids, words in ranked: if len(themes) >= top: break if ids & used: # a story belongs to one theme: the strongest continue used |= ids members = sorted((s for s in stories if s["id"] in ids), key=lambda s: -s["points"]) themes.append({"label": " ".join(sorted(words)), "score": round(sum(weight[i] for i in ids), 2), "stories": members})json.dump({"collected": len(stories), "windowHours": window, "themes": themes}, open("/workspace/signals.json", "w"), indent=1)print(f"{len(stories)} stories in {window:g} h; {len(groups)} shared terms; top themes:")for t in themes: print(f" {t['score']:6.2f} {t['label']} ({len(t['stories'])} stories)")Inputs
Environment variables the example reads:
WINDOW_HOURS(default 48),MIN_POINTS(default 50),TOPthemes (default 3),HN_SEARCH_URL.
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/tech-signals. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.
Run from this folder (both versions upload signals.py from it).
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.
What its check proves
check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.
Each prediction has a short claim, a confidence and at least 2 facts, every fact citing one of its own theme's stories. On the runner's stand-in stories: exactly two themes, WebGPU (3 stories) first and Rust in the Linux kernel (2) second, the off-theme stories left alone, and each prediction quoting facts that are only on its own pages.
Source
github.com/Boxline-dev/examples/tech-signals on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.