-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhn-comment-watch.js
More file actions
162 lines (136 loc) · 4.77 KB
/
Copy pathhn-comment-watch.js
File metadata and controls
162 lines (136 loc) · 4.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// hn-comment-watch.js
import 'dotenv/config';
import { initializeApp } from 'firebase/app';
import { getDatabase, ref, onValue, get } from 'firebase/database';
const storyId = Number(process.env.HN_STORY_ID || process.argv[2]);
const app = initializeApp({
databaseURL: 'https://hacker-news.firebaseio.com',
});
const db = getDatabase(app);
// Tracks every comment id we already know about (anywhere in the tree).
const seen = new Set();
// Tracks item ids we've already attached a kids-listener to (avoid duplicates).
const watching = new Set();
// While true, we're seeding the existing tree and must NOT send notifications.
let seeding = true;
function stripHtml(html = '') {
return html
.replace(/<p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.trim();
}
async function sendPushover({ title, message, url, url_title, priority = '1', sound = 'updown' }) {
const params = {
token: process.env.PUSHOVER_APP_TOKEN,
user: process.env.PUSHOVER_USER_KEY,
title,
message: message.slice(0, 1024),
priority,
sound,
};
if (url) {
params.url = url;
params.url_title = url_title || 'Open';
}
const res = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
});
if (!res.ok) {
throw new Error(`Pushover failed: ${res.status} ${await res.text()}`);
}
}
async function notifyComment(comment) {
const author = comment.by || 'unknown';
const text = stripHtml(comment.text || '(no text)');
const url = `https://news.ycombinator.com/item?id=${comment.id}`;
await sendPushover({
title: `New HN comment by ${author}`,
message: text || `New comment on HN story ${storyId}`,
url,
url_title: 'Open comment',
});
console.log(`Notified for comment ${comment.id} by ${author}`);
}
// Attach a listener to one item's kids. Any new child id triggers a notification
// (unless we're still seeding) and recursively gets its own watcher, so replies
// at any depth — including replies to comments added after startup — are caught.
function watch(itemId) {
if (watching.has(itemId)) return;
watching.add(itemId);
onValue(ref(db, `v0/item/${itemId}/kids`), async (snap) => {
const ids = Object.values(snap.val() || {});
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
// Always recurse so we keep watching deeper into the tree.
watch(id);
if (seeding) continue;
try {
const childSnap = await get(ref(db, `v0/item/${id}`));
const comment = childSnap.val();
if (!comment || comment.deleted || comment.dead || comment.type !== 'comment') {
continue;
}
await notifyComment(comment);
} catch (err) {
console.error(`Failed to notify for comment ${id}:`, err.message);
}
}
});
}
// Recursively pre-load the existing comment tree so we don't notify for
// comments that were already there when the script started.
async function seedExistingTree(itemId) {
const snap = await get(ref(db, `v0/item/${itemId}`));
const item = snap.val();
const kids = item?.kids || [];
for (const id of kids) {
if (!seen.has(id)) {
seen.add(id);
await seedExistingTree(id);
}
}
}
async function main() {
const storySnap = await get(ref(db, `v0/item/${storyId}`));
const story = storySnap.val();
if (!story) {
throw new Error(`Story ${storyId} not found`);
}
// Seed the full existing tree first.
await seedExistingTree(storyId);
// descendants = HN's own total comment count for the story.
const total = typeof story.descendants === 'number' ? story.descendants : seen.size;
const storyUrl = `https://news.ycombinator.com/item?id=${storyId}`;
// Initial test message so you know Pushover is wired up correctly.
await sendPushover({
title: 'HN comment watcher started',
message:
`Watching "${story.title || storyId}".\n` +
`Current total comments: ${total}.\n` +
`You'll be notified for every NEW comment from now on.`,
url: storyUrl,
url_title: 'Open story',
priority: '0',
sound: 'pushover',
});
console.log(`Sent startup test notification. Story has ${total} comments.`);
// Now attach live listeners across the whole known tree. seeding=false means
// any genuinely new child triggers a push notification.
seeding = false;
watch(storyId);
for (const id of seen) watch(id);
console.log(`Watching HN story ${storyId}. Seeded ${seen.size} existing comments.`);
}
main().catch((err) => {
console.error('Fatal:', err.message);
process.exit(1);
});
process.on('SIGINT', () => process.exit(0));