hn-bits 0.7.0 โ†’ 0.7.1

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.
@@ -27,14 +27,15 @@ export function findAlerter() {
27
27
  }
28
28
  return null;
29
29
  }
30
- function wrapperArguments({ subscription, story }, timeoutSeconds, alerterPath) {
30
+ function wrapperArguments({ subscriptions, story }, timeoutSeconds, alerterPath) {
31
+ const names = subscriptions.map((s) => s.name).join(', ');
31
32
  return [
32
33
  'sh',
33
- `๐Ÿ”” ${subscription.name}`,
34
+ `๐Ÿ”” ${names}`,
34
35
  `${story.score} pts ยท ${story.descendants} comments`,
35
36
  story.title,
36
37
  String(timeoutSeconds),
37
- `hn-${subscription.id}-${story.id}`,
38
+ `hn-${story.id}`,
38
39
  story.url ?? hnItemUrl(story.id),
39
40
  alerterPath,
40
41
  ];
@@ -5,8 +5,9 @@ export class NotifyError extends Error {
5
5
  function escapeHtml(text) {
6
6
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
7
  }
8
- function buildMessage({ subscription, story }) {
9
- const lines = [`๐Ÿ”” <b>${escapeHtml(subscription.name)}</b>`];
8
+ function buildMessage({ subscriptions, story }) {
9
+ const names = subscriptions.map((s) => s.name).join(', ');
10
+ const lines = [`๐Ÿ”” <b>${escapeHtml(names)}</b>`];
10
11
  if (story.url) {
11
12
  lines.push(`<a href="${story.url}">${escapeHtml(story.title)}</a>`);
12
13
  lines.push(`${story.score} points ยท ${story.descendants} comments ยท by ${escapeHtml(story.by)}`);
package/dist/watch.js CHANGED
@@ -32,57 +32,77 @@ function buildNotifiers() {
32
32
  }
33
33
  return { notifiers, desktopSkipped };
34
34
  }
35
- async function notifyStory(sub, story, notifiers) {
35
+ /** Query plus unseen filter for one subscription. null on query failure. */
36
+ async function fetchMatches(sub, now) {
36
37
  try {
37
- for (const notifier of notifiers)
38
- await notifier.send({ subscription: sub, story });
39
- return true;
40
- }
41
- catch (err) {
42
- log(`${sub.name}: notify failed for ${story.id} - ${err.message}`);
43
- return false;
44
- }
45
- }
46
- async function processSubscription(sub, notifiers, now, dryRun) {
47
- let stories;
48
- try {
49
- stories = await searchRecent(sub.query, {
38
+ const stories = await searchRecent(sub.query, {
50
39
  createdAfter: windowStart(sub.lastRunAt, now),
51
40
  minPoints: sub.minPoints,
52
41
  minComments: sub.minComments,
53
42
  });
43
+ return stories.filter((story) => !isSeen(story.id, sub.id));
54
44
  }
55
45
  catch (err) {
56
46
  log(`${sub.name}: query failed - ${err.message}`);
57
- return { notified: 0, hadFailure: true };
47
+ return null;
58
48
  }
59
- const matches = stories.filter((story) => !isSeen(story.id, sub.id)).sort((a, b) => a.time - b.time);
60
- if (matches.length === 0) {
61
- log(`${sub.name}: no new matches`);
62
- if (!dryRun)
63
- touchLastRun(sub.id, now);
64
- return { notified: 0, hadFailure: false };
49
+ }
50
+ /** Phase A: fetch every subscription, merging stories matched by more than one. */
51
+ async function collectPending(subs, now) {
52
+ const pending = new Map();
53
+ const fetchedSubs = [];
54
+ let failedSubs = 0;
55
+ for (const sub of subs) {
56
+ const matches = await fetchMatches(sub, now);
57
+ if (matches === null) {
58
+ failedSubs++;
59
+ continue;
60
+ }
61
+ fetchedSubs.push(sub);
62
+ for (const story of matches) {
63
+ const entry = pending.get(story.id);
64
+ if (entry)
65
+ entry.subscriptions.push(sub);
66
+ else
67
+ pending.set(story.id, { story, subscriptions: [sub] });
68
+ }
65
69
  }
66
- log(`${sub.name}: ${matches.length} new matches`);
67
- let notified = 0;
70
+ return { pending, fetchedSubs, failedSubs };
71
+ }
72
+ async function notifyStory(subscriptions, story, notifiers) {
73
+ const names = subscriptions.map((s) => s.name).join(', ');
74
+ try {
75
+ for (const notifier of notifiers)
76
+ await notifier.send({ subscriptions, story });
77
+ return true;
78
+ }
79
+ catch (err) {
80
+ log(`${names}: notify failed for ${story.id} - ${err.message}`);
81
+ return false;
82
+ }
83
+ }
84
+ /** Phase B: one notify per story, oldest first. */
85
+ async function dispatchPending(pending, notifiers, now, dryRun) {
86
+ const ordered = [...pending.values()].sort((a, b) => a.story.time - b.story.time);
87
+ let totalNotified = 0;
68
88
  let hadFailure = false;
69
- for (const story of matches) {
89
+ for (const { story, subscriptions } of ordered) {
90
+ const names = subscriptions.map((s) => s.name).join(', ');
70
91
  if (dryRun) {
71
- log(`would notify: [${sub.name}] ${story.title} (${story.score} pts)`);
92
+ log(`would notify: [${names}] ${story.title} (${story.score} pts)`);
72
93
  continue;
73
94
  }
74
- const sent = await notifyStory(sub, story, notifiers);
95
+ const sent = await notifyStory(subscriptions, story, notifiers);
75
96
  if (!sent) {
76
97
  hadFailure = true;
77
98
  continue;
78
99
  }
79
- markSeen(story.id, sub.id, now);
80
- log(`${sub.name}: notified ${story.id} "${story.title}" (${story.score} pts)`);
81
- notified++;
100
+ for (const sub of subscriptions)
101
+ markSeen(story.id, sub.id, now);
102
+ log(`${names}: notified ${story.id} "${story.title}" (${story.score} pts)`);
103
+ totalNotified++;
82
104
  }
83
- if (!dryRun)
84
- touchLastRun(sub.id, now);
85
- return { notified, hadFailure };
105
+ return { totalNotified, hadFailure };
86
106
  }
87
107
  /** hn watch --once: one-shot pass over all subscriptions. Returns the process exit code. */
88
108
  export async function runWatch(options) {
@@ -100,14 +120,13 @@ export async function runWatch(options) {
100
120
  }
101
121
  log(`watch: ${subs.length} subscriptions`);
102
122
  const now = Math.floor(Date.now() / 1000);
103
- let totalNotified = 0;
104
- let failedSubs = 0;
105
- for (const sub of subs) {
106
- const result = await processSubscription(sub, notifiers, now, options.dryRun);
107
- totalNotified += result.notified;
108
- if (result.hadFailure)
109
- failedSubs++;
110
- }
123
+ const { pending, fetchedSubs, failedSubs } = await collectPending(subs, now);
124
+ if (pending.size === 0)
125
+ log('no new matches');
126
+ const { totalNotified, hadFailure } = await dispatchPending(pending, notifiers, now, options.dryRun);
127
+ if (!options.dryRun)
128
+ for (const sub of fetchedSubs)
129
+ touchLastRun(sub.id, now);
111
130
  log(`done: ${totalNotified} notified, ${failedSubs} failed`);
112
- return failedSubs > 0 ? 1 : 0;
131
+ return failedSubs > 0 || hadFailure ? 1 : 0;
113
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {