Top story discussion
Take today's most-voted Hacker News story, read the article and its discussion in a cloud browser, and sum up both: what the article says, the viewpoints in the comments (each with a short quote and how many hold it), where people agree and disagree, and what is still open.
When to use it: You want the day's top Hacker News story summed up together with the viewpoints in its comments.
| Uses | extract over the article and the discussion page |
|---|---|
| Needs | a model on the API (no model key of your own) |
| Site | Hacker News (its official search API for the front page; the story and its discussion page); the runner uses stand-in pages |
| Output | output/discussion.md, output/result.json |
| Languages | Node and Python |
The story: the most-voted one of the last HOURS hours that links to an article and has at least MIN_COMMENTS comments (Ask HN posts have no article). The comments are data for the model: it quotes them and never follows what they say.
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.
/** * Top story discussion: take today's most-voted Hacker News story, read the article and its discussion in a * sandboxed browser, and sum up both: what the article says, the viewpoints in the comments (with a quote each), * where people agree and disagree, and what is still open. * * npx tsx node/index.ts (BOXLINE_API_KEY; HOURS, MIN_COMMENTS) * * The front page comes from HN's official search API (plain JSON, no browser needed); one extract call reads the * article and the discussion page together. Writes output/result.json and output/discussion.md. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const frontUrl = process.env.HN_FRONT_URL ?? "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=50";const itemUrl = process.env.HN_ITEM_URL ?? "https://news.ycombinator.com/item?id={id}";const hours = Number(process.env.HOURS ?? 24);const minComments = Number(process.env.MIN_COMMENTS ?? 20);const bx = new Boxline();type Hit = { objectID: string; title: string; url: string | null; points: number; num_comments: number; created_at_i: number };type Summary = { article: { summary: string; keyPoints: string[] }; discussion: { summary: string; viewpoints: { view: string; support: "many" | "some" | "few"; quote: string }[]; agreements: string[]; disagreements: string[]; openQuestions: string[] };};// 1. Today's most-voted story with a link to an article and a real discussion.const front = (await (await fetch(frontUrl)).json()) as { hits: Hit[] };const since = Date.now() / 1000 - hours * 3600;const story = front.hits.filter((h) => h.url && h.created_at_i >= since && h.num_comments >= minComments).sort((a, b) => b.points - a.points)[0];if (!story) throw new Error(`no story of the last ${hours} hours has a link and ${minComments}+ comments`);const discussionUrl = itemUrl.replace("{id}", story.objectID);console.log(`Top story: ${story.title} (${story.points} points, ${story.num_comments} comments)\n ${story.url}\n ${discussionUrl}`);// 2. The article and the discussion, read together. Comments are data: the model quotes them, never follows them.const r = await bx.extract<Summary>({ urls: [story.url!, discussionUrl], prompt: `The first page is an article, the second its Hacker News discussion. article: what it says (summary, 3 to 5 key points). ` + `discussion: the main viewpoints in the comments, each with how many people hold it (many, some or few) and a short ` + `quote copied exactly from one comment; where commenters agree and disagree; and the questions still open.`, schema: { type: "object", properties: { article: { type: "object", properties: { summary: { type: "string" }, keyPoints: { type: "array", items: { type: "string" } } }, required: ["summary", "keyPoints"] }, discussion: { type: "object", properties: { summary: { type: "string" }, viewpoints: { type: "array", items: { type: "object", properties: { view: { type: "string" }, support: { type: "string", enum: ["many", "some", "few"] }, quote: { type: "string" } }, required: ["view", "support", "quote"] }, }, agreements: { type: "array", items: { type: "string" } }, disagreements: { type: "array", items: { type: "string" } }, openQuestions: { type: "array", items: { type: "string" } }, }, required: ["summary", "viewpoints", "agreements", "disagreements", "openQuestions"], }, }, required: ["article", "discussion"], },});const { article, discussion } = r.data;const failed = r.pages.filter((p) => p.error || (p.status ?? 0) >= 400);if (failed.length) console.log(`Could not read: ${failed.map((p) => `${p.url} (${p.error?.code ?? `HTTP ${p.status}`})`).join(", ")}`);const md = [ `# ${story.title}`, "", `${story.points} points · ${story.num_comments} comments · [article](${story.url}) · [discussion](${discussionUrl})`, "", "## The article", "", article.summary, "", ...article.keyPoints.map((k) => `- ${k}`), "", "## The discussion", "", discussion.summary, "", ...discussion.viewpoints.map((v) => `- **${v.view}** (${v.support}): "${v.quote}"`), "", `**Agreed:** ${discussion.agreements.join("; ") || "nothing in particular"}`, "", `**Disputed:** ${discussion.disagreements.join("; ") || "nothing in particular"}`, "", `**Still open:** ${discussion.openQuestions.join("; ") || "nothing"}`, "",].join("\n");mkdirSync(out, { recursive: true });writeFileSync(join(out, "discussion.md"), md);writeFileSync(join(out, "result.json"), JSON.stringify({ story: { ...story, discussionUrl }, article, discussion, pages: r.pages, usage: { modelUsd: r.usage.costUsd } }, null, 2));console.log(`\n${md}`);"""Top story discussion: take today's most-voted Hacker News story, read the article and its discussion in asandboxed browser, and sum up both: what the article says, the viewpoints in the comments (with a quote each),where people agree and disagree, and what is still open. python python/main.py (BOXLINE_API_KEY; HOURS, MIN_COMMENTS)The front page comes from HN's official search API (plain JSON, no browser needed); one extract call reads thearticle and the discussion page together. Writes output/result.json and output/discussion.md."""import jsonimport osimport timeimport urllib.requestfrom pathlib import Pathfrom boxline import Boxlineout = Path(os.environ.get("OUTPUT_DIR", "output"))front_url = os.environ.get("HN_FRONT_URL", "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=50")item_url = os.environ.get("HN_ITEM_URL", "https://news.ycombinator.com/item?id={id}")hours = float(os.environ.get("HOURS", "24"))min_comments = int(os.environ.get("MIN_COMMENTS", "20"))bx = Boxline()# 1. Today's most-voted story with a link to an article and a real discussion.with urllib.request.urlopen(urllib.request.Request(front_url, headers={"user-agent": "top-story-discussion (a Boxline example)"}), timeout=30) as res: hits = json.load(res)["hits"]since = time.time() - hours * 3600fresh = [h for h in hits if h.get("url") and h["created_at_i"] >= since and (h.get("num_comments") or 0) >= min_comments]if not fresh: raise SystemExit(f"no story of the last {hours:g} hours has a link and {min_comments}+ comments")story = max(fresh, key=lambda h: h["points"])discussion_url = item_url.replace("{id}", story["objectID"])print(f"Top story: {story['title']} ({story['points']} points, {story['num_comments']} comments)\n {story['url']}\n {discussion_url}")# 2. The article and the discussion, read together. Comments are data: the model quotes them, never follows them.r = bx.extract( urls=[story["url"], discussion_url], prompt=( "The first page is an article, the second its Hacker News discussion. article: what it says (summary, 3 to 5 key points). " "discussion: the main viewpoints in the comments, each with how many people hold it (many, some or few) and a short " "quote copied exactly from one comment; where commenters agree and disagree; and the questions still open." ), schema={ "type": "object", "properties": { "article": {"type": "object", "properties": {"summary": {"type": "string"}, "keyPoints": {"type": "array", "items": {"type": "string"}}}, "required": ["summary", "keyPoints"]}, "discussion": { "type": "object", "properties": { "summary": {"type": "string"}, "viewpoints": { "type": "array", "items": {"type": "object", "properties": {"view": {"type": "string"}, "support": {"type": "string", "enum": ["many", "some", "few"]}, "quote": {"type": "string"}}, "required": ["view", "support", "quote"]}, }, "agreements": {"type": "array", "items": {"type": "string"}}, "disagreements": {"type": "array", "items": {"type": "string"}}, "openQuestions": {"type": "array", "items": {"type": "string"}}, }, "required": ["summary", "viewpoints", "agreements", "disagreements", "openQuestions"], }, }, "required": ["article", "discussion"], },)article, discussion = r["data"]["article"], r["data"]["discussion"]failed = [p for p in r["pages"] if p.get("error") or (p.get("status") or 0) >= 400]if failed: print("Could not read: " + ", ".join(f"{p['url']} ({(p.get('error') or {}).get('code') or 'HTTP ' + str(p['status'])})" for p in failed))md = [f"# {story['title']}", "", f"{story['points']} points · {story['num_comments']} comments · [article]({story['url']}) · [discussion]({discussion_url})", "", "## The article", "", article["summary"], ""]md += [f"- {k}" for k in article["keyPoints"]] + ["", "## The discussion", "", discussion["summary"], ""]md += [f"- **{v['view']}** ({v['support']}): \"{v['quote']}\"" for v in discussion["viewpoints"]] + [""]md += [f"**Agreed:** {'; '.join(discussion['agreements']) or 'nothing in particular'}", "", f"**Disputed:** {'; '.join(discussion['disagreements']) or 'nothing in particular'}", "", f"**Still open:** {'; '.join(discussion['openQuestions']) or 'nothing'}", ""]md = "\n".join(md)out.mkdir(parents=True, exist_ok=True)(out / "discussion.md").write_text(md)result = {"story": {**story, "discussionUrl": discussion_url}, "article": article, "discussion": discussion, "pages": r["pages"], "usage": {"modelUsd": r["usage"]["costUsd"]}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))print(f"\n{md}")Inputs
Environment variables the example reads:
HOURS(default 24),MIN_COMMENTS(default 20).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/top-story-discussion. 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.
# 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.
The article has a summary and key points, the discussion at least two viewpoints, each with a quote. On the runner's stand-in front page: the fresh, linked and discussed story is chosen (an Ask HN post, an older story and one with 5 comments are left out), the article summary holds its fact, every quote is from a comment, the licence is the dispute and durability the open question.
Source
github.com/Boxline-dev/examples/top-story-discussion 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.