Skip to main content

max / balanced_breakfast

5.7 KB · 238 lines History Blame Raw
1 // Hacker News story feed. Fetches story IDs then individual items from
2 // the Firebase JSON API. Supports top/new/best/ask/show/job story types.
3 // Strips basic HTML from self-post text; paginates via offset cursor.
4
5 const HN_API = "https://hacker-news.firebaseio.com/v0";
6
7 fn id() {
8 "hackernews"
9 }
10
11 fn name() {
12 "Hacker News"
13 }
14
15 fn capabilities() {
16 #{
17 supports_pagination: true
18 }
19 }
20
21 fn config_schema() {
22 #{
23 description: "Fetch stories from Hacker News. Choose which type of stories to follow.",
24 fields: [
25 #{
26 key: "story_type",
27 label: "Story Type",
28 field_type: "select",
29 required: true,
30 description: "Which type of HN stories to fetch",
31 default_value: "top",
32 options: ["top", "new", "best", "ask", "show", "job"]
33 },
34 #{
35 key: "limit",
36 label: "Items per fetch",
37 field_type: "text",
38 description: "Number of stories to fetch (max 100)",
39 default_value: "30",
40 placeholder: "30"
41 }
42 ]
43 }
44 }
45
46 fn fetch(config, cursor) {
47 // Validate story type
48 let story_type = "top";
49 if config.story_type != () {
50 story_type = config.story_type;
51 let valid = ["top", "new", "best", "ask", "show", "job"];
52 let found = false;
53 for v in valid {
54 if story_type == v { found = true; }
55 }
56 if !found {
57 throw_config_error("Invalid story type: " + story_type);
58 }
59 }
60
61 // Get limit from config
62 let limit = 30;
63 if config.limit != () {
64 let parsed = parse_int(config.limit);
65 if parsed != () && parsed > 0 {
66 if parsed > 100 {
67 limit = 100;
68 } else {
69 limit = parsed;
70 }
71 }
72 }
73
74 // Parse cursor for offset
75 let offset = 0;
76 if cursor != () {
77 let parsed = parse_int(cursor);
78 if parsed != () {
79 offset = parsed;
80 }
81 }
82
83 // Fetch story IDs
84 let endpoint = get_endpoint(story_type);
85 let ids_url = HN_API + "/" + endpoint + ".json";
86 let all_ids = http_get_json(ids_url);
87
88 // Slice to get the items we want
89 let ids = [];
90 let i = offset;
91 while i < all_ids.len() && ids.len() < limit {
92 ids.push(all_ids[i]);
93 i += 1;
94 }
95
96 // Fetch each story
97 let items = [];
98 for story_id in ids {
99 let item = fetch_story(story_id, story_type, HN_API);
100 if item != () {
101 items.push(item);
102 }
103 }
104
105 // Calculate next cursor
106 let next_offset = offset + limit;
107 let has_more = next_offset < 500;
108
109 let result = #{
110 items: items,
111 has_more: has_more
112 };
113
114 if has_more {
115 result.next_cursor = "" + next_offset;
116 }
117
118 result
119 }
120
121 fn get_endpoint(story_type) {
122 if story_type == "new" { return "newstories"; }
123 if story_type == "best" { return "beststories"; }
124 if story_type == "ask" { return "askstories"; }
125 if story_type == "show" { return "showstories"; }
126 if story_type == "job" { return "jobstories"; }
127 "topstories"
128 }
129
130 fn fetch_story(story_id, story_type, api_base) {
131 let url = api_base + "/item/" + story_id + ".json";
132 let item = http_get_json(url);
133
134 if item == () {
135 return ();
136 }
137 if item.title == () {
138 return ();
139 }
140
141 let title = item.title;
142
143 let score = 0;
144 if item.score != () {
145 score = item.score;
146 }
147
148 let comments = 0;
149 if item.descendants != () {
150 comments = item.descendants;
151 }
152
153 let author = "anonymous";
154 if item.by != () {
155 author = item.by;
156 }
157
158 let time = timestamp_now();
159 if item.time != () {
160 time = item.time;
161 }
162
163 let item_url = item.url;
164 let text = item.text;
165
166 let item_type = "story";
167 if item.type != () {
168 item_type = item.type;
169 }
170
171 // Determine indicator
172 let indicator = "๐Ÿ”—";
173 if item_type == "job" {
174 indicator = "๐Ÿ’ผ";
175 } else if item_type == "poll" {
176 indicator = "๐Ÿ“Š";
177 } else if item_url == () {
178 indicator = "๐Ÿ’ฌ";
179 }
180
181 // Create secondary text
182 let secondary = "" + score + " pts ยท " + comments + " comments";
183
184 // Create content
185 let body = ();
186 if text != () {
187 body = text;
188 body = str_replace(body, "<p>", "\n\n");
189 body = str_replace(body, "</p>", "");
190 body = str_replace(body, "<i>", "_");
191 body = str_replace(body, "</i>", "_");
192 body = str_replace(body, "<b>", "**");
193 body = str_replace(body, "</b>", "**");
194 body = str_replace(body, "&gt;", ">");
195 body = str_replace(body, "&lt;", "<");
196 body = str_replace(body, "&amp;", "&");
197 body = str_replace(body, "&#x27;", "'");
198 body = str_replace(body, "&quot;", `"`);
199 }
200
201 // URL
202 let content_url = "https://news.ycombinator.com/item?id=" + story_id;
203 if item_url != () {
204 content_url = item_url;
205 }
206
207 // Source name
208 let source_name = "Hacker News";
209 if story_type == "ask" {
210 source_name = "Ask HN";
211 } else if story_type == "show" {
212 source_name = "Show HN";
213 } else if story_type == "job" {
214 source_name = "HN Jobs";
215 }
216
217 #{
218 id: #{ source: "hackernews", item_id: "" + story_id },
219 bite: #{
220 author: "HN",
221 text: title,
222 secondary: secondary,
223 indicator: indicator
224 },
225 content: #{
226 title: title,
227 body: body,
228 url: content_url
229 },
230 meta: #{
231 source_name: source_name,
232 published_at: time,
233 score: score,
234 tags: ["hackernews"]
235 }
236 }
237 }
238