@calesennett/pi-hn 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/extensions/hn.ts +232 -16
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -7,10 +7,11 @@ A simple Hacker News front-page reader extension for [pi](https://github.com/bad
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- `/hn` command opens a selectable front-page list
|
|
10
|
-
- Read tracking is stored in a local JSON file at `~/.pi/agent/data/pi-hn/db.json` (or `$PI_CODING_AGENT_DIR/data/pi-hn/db.json`)
|
|
11
10
|
- `j/k` (and arrow keys) navigate the list
|
|
12
11
|
- `a` or `Enter` opens the article URL
|
|
12
|
+
- `x` fetches and parses the selected article (via Readability) and adds it to session context for follow-up prompts
|
|
13
13
|
- `c` opens comments in browser
|
|
14
|
+
- Read tracking is stored in a local JSON file at `~/.pi/agent/data/pi-hn/db.json` (or `$PI_CODING_AGENT_DIR/data/pi-hn/db.json`)
|
|
14
15
|
|
|
15
16
|
## Install
|
|
16
17
|
|
package/extensions/hn.ts
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Readability } from "@mozilla/readability";
|
|
3
4
|
import { Container, SelectList, Text } from "@mariozechner/pi-tui";
|
|
5
|
+
import { JSDOM, VirtualConsole } from "jsdom";
|
|
4
6
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
8
|
import { dirname, join } from "node:path";
|
|
7
9
|
|
|
8
10
|
const HN_FRONT_PAGE_API = "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30";
|
|
11
|
+
const ARTICLE_CONTEXT_MESSAGE_TYPE = "hn-article-context";
|
|
12
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
13
|
+
|
|
14
|
+
interface ReadableArticle {
|
|
15
|
+
title: string;
|
|
16
|
+
url: string;
|
|
17
|
+
byline: string | null;
|
|
18
|
+
siteName: string | null;
|
|
19
|
+
excerpt: string | null;
|
|
20
|
+
textContent: string;
|
|
21
|
+
length: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ArticleContextDetails {
|
|
25
|
+
hnId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
url: string;
|
|
28
|
+
siteName: string | null;
|
|
29
|
+
byline: string | null;
|
|
30
|
+
length: number;
|
|
31
|
+
charCount: number;
|
|
32
|
+
fetchedAt: string;
|
|
33
|
+
}
|
|
9
34
|
|
|
10
35
|
interface HNHit {
|
|
11
36
|
title: string | null;
|
|
@@ -248,6 +273,86 @@ function commentsUrl(hit: HNHit): string {
|
|
|
248
273
|
return `https://news.ycombinator.com/item?id=${encodeURIComponent(hit.objectID)}`;
|
|
249
274
|
}
|
|
250
275
|
|
|
276
|
+
function normalizeArticleText(text: string): string {
|
|
277
|
+
return text
|
|
278
|
+
.replace(/\u00a0/g, " ")
|
|
279
|
+
.replace(/\r/g, "")
|
|
280
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
281
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
282
|
+
.trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function createArticleDom(html: string, url: string): JSDOM {
|
|
286
|
+
const virtualConsole = new VirtualConsole();
|
|
287
|
+
virtualConsole.on("jsdomError", (error) => {
|
|
288
|
+
if (error instanceof Error && error.message.includes("Could not parse CSS stylesheet")) return;
|
|
289
|
+
console.error(error);
|
|
290
|
+
});
|
|
291
|
+
return new JSDOM(html, { url, virtualConsole });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function fetchReadableArticle(url: string): Promise<ReadableArticle> {
|
|
295
|
+
const response = await fetch(url, {
|
|
296
|
+
headers: {
|
|
297
|
+
accept: "text/html,application/xhtml+xml",
|
|
298
|
+
"user-agent": "pi-hn/0.1",
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
throw new Error(`Article request returned ${response.status}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const html = await response.text();
|
|
306
|
+
const resolvedUrl = response.url || url;
|
|
307
|
+
const dom = createArticleDom(html, resolvedUrl);
|
|
308
|
+
const fallbackDom = createArticleDom(html, resolvedUrl);
|
|
309
|
+
fallbackDom.window.document.querySelectorAll("script, style").forEach((node) => node.remove());
|
|
310
|
+
const fallbackText = normalizeArticleText(fallbackDom.window.document.body?.textContent ?? "");
|
|
311
|
+
const parsed = new Readability(dom.window.document).parse();
|
|
312
|
+
const textContent = normalizeArticleText(parsed?.textContent ?? fallbackText);
|
|
313
|
+
if (textContent.length === 0) {
|
|
314
|
+
throw new Error("Article had no readable text content");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
title: normalizeTitle(parsed?.title ?? dom.window.document.title),
|
|
319
|
+
url: resolvedUrl,
|
|
320
|
+
byline: parsed?.byline?.trim() || null,
|
|
321
|
+
siteName: parsed?.siteName?.trim() || null,
|
|
322
|
+
excerpt: parsed?.excerpt?.trim() || null,
|
|
323
|
+
textContent,
|
|
324
|
+
length: parsed?.length ?? textContent.length,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function buildArticleContext(hit: HNHit, article: ReadableArticle): { content: string; details: ArticleContextDetails } {
|
|
329
|
+
const title = article.title === "(untitled)" ? normalizeTitle(hit.title) : article.title;
|
|
330
|
+
const lines: string[] = [
|
|
331
|
+
`Title: ${title}`,
|
|
332
|
+
`URL: ${article.url}`,
|
|
333
|
+
`HN Comments: ${commentsUrl(hit)}`,
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
if (article.siteName) lines.push(`Site: ${article.siteName}`);
|
|
337
|
+
if (article.byline) lines.push(`Byline: ${article.byline}`);
|
|
338
|
+
if (article.excerpt) lines.push(`Excerpt: ${article.excerpt}`);
|
|
339
|
+
lines.push("", "Article Text:", article.textContent);
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
content: lines.join("\n"),
|
|
343
|
+
details: {
|
|
344
|
+
hnId: hit.objectID,
|
|
345
|
+
title,
|
|
346
|
+
url: article.url,
|
|
347
|
+
siteName: article.siteName,
|
|
348
|
+
byline: article.byline,
|
|
349
|
+
length: article.length,
|
|
350
|
+
charCount: article.textContent.length,
|
|
351
|
+
fetchedAt: new Date().toISOString(),
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
251
356
|
async function openInBrowser(pi: ExtensionAPI, url: string): Promise<{ ok: boolean; error?: string }> {
|
|
252
357
|
const windowsQuotedUrl = `"${url.replace(/"/g, '""')}"`;
|
|
253
358
|
const result =
|
|
@@ -298,8 +403,28 @@ async function fetchFrontPage(): Promise<HNHit[]> {
|
|
|
298
403
|
}
|
|
299
404
|
|
|
300
405
|
export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
406
|
+
pi.registerMessageRenderer(ARTICLE_CONTEXT_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
407
|
+
const details = (message.details ?? {}) as Partial<ArticleContextDetails>;
|
|
408
|
+
const title = details.title ?? "(untitled)";
|
|
409
|
+
const charCount = typeof details.charCount === "number" ? details.charCount : undefined;
|
|
410
|
+
const charLabel =
|
|
411
|
+
typeof charCount === "number" ? theme.fg("dim", ` (${charCount.toLocaleString()} chars)`) : "";
|
|
412
|
+
|
|
413
|
+
let text = `${theme.bold(`[${title}]`)}${charLabel}`;
|
|
414
|
+
if (expanded) {
|
|
415
|
+
if (details.url) text += `\n${theme.fg("muted", `URL: ${details.url}`)}`;
|
|
416
|
+
if (details.siteName) text += `\n${theme.fg("muted", `Site: ${details.siteName}`)}`;
|
|
417
|
+
if (details.byline) text += `\n${theme.fg("muted", `Byline: ${details.byline}`)}`;
|
|
418
|
+
if (details.fetchedAt) {
|
|
419
|
+
text += `\n${theme.fg("dim", `Fetched: ${new Date(details.fetchedAt).toLocaleString()}`)}`;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return new Text(text, 0, 0);
|
|
424
|
+
});
|
|
425
|
+
|
|
301
426
|
pi.registerCommand("hn", {
|
|
302
|
-
description: "Browse Hacker News front page
|
|
427
|
+
description: "Browse Hacker News front page (a/enter=article, x=add context, c=comments)",
|
|
303
428
|
handler: async (_args, ctx) => {
|
|
304
429
|
if (!ctx.hasUI) return;
|
|
305
430
|
|
|
@@ -345,7 +470,21 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
|
345
470
|
|
|
346
471
|
const pendingReadHits = new Map<string, HNHit>();
|
|
347
472
|
let uiClosed = false;
|
|
473
|
+
const hintText = [
|
|
474
|
+
`${theme.fg("dim", theme.bold("↑↓/j/k"))} ${theme.fg("dim", "navigate")}`,
|
|
475
|
+
`${theme.fg("dim", theme.bold("enter/a"))} ${theme.fg("dim", "article")}`,
|
|
476
|
+
`${theme.fg("dim", theme.bold("x"))} ${theme.fg("dim", "add to context")}`,
|
|
477
|
+
`${theme.fg("dim", theme.bold("c"))} ${theme.fg("dim", "comments")}`,
|
|
478
|
+
`${theme.fg("dim", theme.bold("esc"))} ${theme.fg("dim", "close")}`,
|
|
479
|
+
].join(theme.fg("dim", " • "));
|
|
480
|
+
const hint = new Text(hintText);
|
|
481
|
+
let contextLoadHit: HNHit | null = null;
|
|
482
|
+
let spinnerFrame = 0;
|
|
483
|
+
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
484
|
+
|
|
348
485
|
const flushPendingReads = () => {
|
|
486
|
+
if (pendingReadHits.size === 0) return;
|
|
487
|
+
|
|
349
488
|
const result = persistReadArticles([...pendingReadHits.values()]);
|
|
350
489
|
if (!result.ok) {
|
|
351
490
|
ctx.ui.notify(
|
|
@@ -357,9 +496,42 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
|
357
496
|
pendingReadHits.clear();
|
|
358
497
|
};
|
|
359
498
|
|
|
499
|
+
const setHint = (text: string) => {
|
|
500
|
+
hint.setText(text);
|
|
501
|
+
hint.invalidate();
|
|
502
|
+
if (!uiClosed) tui.requestRender();
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const updateHint = (text: string, color: "dim" | "warning" = "dim") => {
|
|
506
|
+
setHint(theme.fg(color, text));
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const stopContextSpinner = () => {
|
|
510
|
+
if (spinnerTimer) {
|
|
511
|
+
clearInterval(spinnerTimer);
|
|
512
|
+
spinnerTimer = undefined;
|
|
513
|
+
}
|
|
514
|
+
contextLoadHit = null;
|
|
515
|
+
spinnerFrame = 0;
|
|
516
|
+
setHint(hintText);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const startContextSpinner = (hit: HNHit) => {
|
|
520
|
+
contextLoadHit = hit;
|
|
521
|
+
spinnerFrame = 0;
|
|
522
|
+
const animate = () => {
|
|
523
|
+
const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "*";
|
|
524
|
+
spinnerFrame += 1;
|
|
525
|
+
updateHint(`${frame} fetching article and adding to session context...`, "warning");
|
|
526
|
+
};
|
|
527
|
+
animate();
|
|
528
|
+
spinnerTimer = setInterval(animate, 110);
|
|
529
|
+
};
|
|
530
|
+
|
|
360
531
|
const closeUi = () => {
|
|
361
532
|
if (uiClosed) return;
|
|
362
533
|
uiClosed = true;
|
|
534
|
+
stopContextSpinner();
|
|
363
535
|
flushPendingReads();
|
|
364
536
|
done();
|
|
365
537
|
};
|
|
@@ -389,6 +561,26 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
|
389
561
|
return hitsById.get(selected.value);
|
|
390
562
|
};
|
|
391
563
|
|
|
564
|
+
const markHitRead = (hit: HNHit) => {
|
|
565
|
+
pendingReadHits.set(hit.objectID, hit);
|
|
566
|
+
readArticleIds.add(hit.objectID);
|
|
567
|
+
|
|
568
|
+
if (uiClosed) {
|
|
569
|
+
flushPendingReads();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const item = itemsById.get(hit.objectID);
|
|
574
|
+
if (item) {
|
|
575
|
+
item.label = formatListLabel(hit, true);
|
|
576
|
+
selectList.invalidate();
|
|
577
|
+
tui.requestRender();
|
|
578
|
+
}
|
|
579
|
+
if (pendingReadHits.size >= 10) {
|
|
580
|
+
flushPendingReads();
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
392
584
|
const openSelectedArticle = async () => {
|
|
393
585
|
const hit = getSelectedHit();
|
|
394
586
|
if (!hit) return;
|
|
@@ -403,22 +595,44 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
|
403
595
|
return;
|
|
404
596
|
}
|
|
405
597
|
|
|
406
|
-
|
|
407
|
-
|
|
598
|
+
markHitRead(hit);
|
|
599
|
+
};
|
|
408
600
|
|
|
409
|
-
|
|
410
|
-
|
|
601
|
+
const addSelectedArticleToContext = async () => {
|
|
602
|
+
const hit = getSelectedHit();
|
|
603
|
+
if (!hit) return;
|
|
604
|
+
if (!hit.url) {
|
|
605
|
+
ctx.ui.notify("This item has no article URL.", "warning");
|
|
411
606
|
return;
|
|
412
607
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
item.label = formatListLabel(hit, true);
|
|
417
|
-
selectList.invalidate();
|
|
418
|
-
tui.requestRender();
|
|
608
|
+
if (contextLoadHit) {
|
|
609
|
+
ctx.ui.notify(`Already fetching: ${normalizeTitle(contextLoadHit.title)}`, "warning");
|
|
610
|
+
return;
|
|
419
611
|
}
|
|
420
|
-
|
|
421
|
-
|
|
612
|
+
|
|
613
|
+
startContextSpinner(hit);
|
|
614
|
+
try {
|
|
615
|
+
const article = await fetchReadableArticle(hit.url);
|
|
616
|
+
if (uiClosed) return;
|
|
617
|
+
const contextMessage = buildArticleContext(hit, article);
|
|
618
|
+
pi.sendMessage(
|
|
619
|
+
{
|
|
620
|
+
customType: ARTICLE_CONTEXT_MESSAGE_TYPE,
|
|
621
|
+
content: contextMessage.content,
|
|
622
|
+
details: contextMessage.details,
|
|
623
|
+
display: true,
|
|
624
|
+
},
|
|
625
|
+
{ triggerTurn: false },
|
|
626
|
+
);
|
|
627
|
+
markHitRead(hit);
|
|
628
|
+
const addedTitle = article.title === "(untitled)" ? normalizeTitle(hit.title) : article.title;
|
|
629
|
+
ctx.ui.notify(`Added to session context: ${addedTitle}`, "info");
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (!uiClosed) {
|
|
632
|
+
ctx.ui.notify(`Could not add article to context: ${getErrorMessage(error)}`, "error");
|
|
633
|
+
}
|
|
634
|
+
} finally {
|
|
635
|
+
stopContextSpinner();
|
|
422
636
|
}
|
|
423
637
|
};
|
|
424
638
|
|
|
@@ -438,9 +652,7 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
|
438
652
|
selectList.onCancel = () => closeUi();
|
|
439
653
|
|
|
440
654
|
container.addChild(selectList);
|
|
441
|
-
container.addChild(
|
|
442
|
-
new Text(theme.fg("dim", "↑↓/j/k navigate • enter/a article • c comments • esc close")),
|
|
443
|
-
);
|
|
655
|
+
container.addChild(hint);
|
|
444
656
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
445
657
|
|
|
446
658
|
return {
|
|
@@ -451,6 +663,10 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
|
451
663
|
container.invalidate();
|
|
452
664
|
},
|
|
453
665
|
handleInput(data: string) {
|
|
666
|
+
if (data === "x" || data === "X") {
|
|
667
|
+
void addSelectedArticleToContext();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
454
670
|
if (data === "a" || data === "A") {
|
|
455
671
|
void openSelectedArticle();
|
|
456
672
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calesennett/pi-hn",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Hacker News front-page reader extension for pi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -16,5 +16,9 @@
|
|
|
16
16
|
"homepage": "https://github.com/calesennett/pi-hn#readme",
|
|
17
17
|
"bugs": {
|
|
18
18
|
"url": "https://github.com/calesennett/pi-hn/issues"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@mozilla/readability": "^0.5.0",
|
|
22
|
+
"jsdom": "^24.1.3"
|
|
19
23
|
}
|
|
20
24
|
}
|