Files
privatecloud builder 95bef8bf7b
CI / build (push) Successful in 2m0s
Build hn-reader: keyboard-driven Hacker News reader
Vite/React + shadcn/ui frontend with Superhuman-style keyboard nav
(j/k/o/v/e/s/u, feed switching, localStorage caching for instant
loads) backed by an Express API that polls the HN Firebase API into
Postgres and serves cached feeds/comments. Instrumented with
OpenTelemetry (traces/metrics/logs via OTLP/HTTP).
2026-07-25 12:16:08 +00:00

161 lines
4.4 KiB
JavaScript

const { pool } = require('./db')
const { log } = require('./otel')
const HN_BASE = 'https://hacker-news.firebaseio.com/v0'
const FEEDS = {
top: 'topstories',
new: 'newstories',
best: 'beststories',
ask: 'askstories',
show: 'showstories',
job: 'jobstories',
}
const ITEMS_PER_FEED = 100
const FETCH_CONCURRENCY = 20
async function fetchJson(url) {
const res = await fetch(url)
if (!res.ok) throw new Error(`HN API ${res.status} for ${url}`)
return res.json()
}
function domainOf(url) {
if (!url) return null
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return null
}
}
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length)
let next = 0
async function worker() {
while (next < items.length) {
const i = next++
results[i] = await fn(items[i], i)
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return results
}
async function fetchItem(id) {
return fetchJson(`${HN_BASE}/item/${id}.json`)
}
async function syncFeed(feedKey) {
const endpoint = FEEDS[feedKey]
const ids = (await fetchJson(`${HN_BASE}/${endpoint}.json`)).slice(0, ITEMS_PER_FEED)
const items = await mapWithConcurrency(ids, FETCH_CONCURRENCY, async (id) => {
try {
return await fetchItem(id)
} catch (err) {
log('ERROR', 'failed to fetch HN item', { id, error: String(err) })
return null
}
})
const client = await pool.connect()
try {
await client.query('BEGIN')
let rank = 0
for (const item of items) {
if (!item || item.deleted || item.dead) continue
await client.query(
`INSERT INTO stories (id, type, title, url, domain, by_user, score, descendants, story_text, hn_created_at, fetched_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, to_timestamp($10), now())
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
url = EXCLUDED.url,
domain = EXCLUDED.domain,
score = EXCLUDED.score,
descendants = EXCLUDED.descendants,
story_text = EXCLUDED.story_text,
fetched_at = now()`,
[
item.id,
item.type || 'story',
item.title || '(untitled)',
item.url || null,
domainOf(item.url),
item.by || null,
item.score || 0,
item.descendants || 0,
item.text || null,
item.time || Math.floor(Date.now() / 1000),
]
)
await client.query(
`INSERT INTO story_state (story_id) VALUES ($1) ON CONFLICT (story_id) DO NOTHING`,
[item.id]
)
await client.query(
`INSERT INTO feed_items (feed, rank, story_id, updated_at) VALUES ($1,$2,$3, now())
ON CONFLICT (feed, rank) DO UPDATE SET story_id = EXCLUDED.story_id, updated_at = now()`,
[feedKey, rank, item.id]
)
rank++
}
// Drop stale ranks past what we just wrote (feed shrank).
await client.query('DELETE FROM feed_items WHERE feed = $1 AND rank >= $2', [feedKey, rank])
await client.query('COMMIT')
log('INFO', 'synced feed', { feed: feedKey, count: rank })
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
async function syncAll() {
for (const feedKey of Object.keys(FEEDS)) {
try {
await syncFeed(feedKey)
} catch (err) {
log('ERROR', 'feed sync failed', { feed: feedKey, error: String(err) })
}
}
}
const MAX_COMMENT_NODES = 300
async function fetchCommentsTree(storyId, maxDepth = 6) {
let count = 0
async function walk(id, depth) {
if (count >= MAX_COMMENT_NODES) return null
const item = await fetchItem(id)
count++
if (!item || item.deleted || item.dead) return null
let kids = []
if (item.kids && depth < maxDepth && count < MAX_COMMENT_NODES) {
const childItems = await mapWithConcurrency(
item.kids,
FETCH_CONCURRENCY,
(kidId) => walk(kidId, depth + 1)
)
kids = childItems.filter(Boolean)
}
return {
id: item.id,
by: item.by || null,
text: item.text || null,
time: item.time || null,
kids,
}
}
return Promise.all((await fetchItem(storyId)).kids?.slice(0, 40).map((id) => walk(id, 1)) || [])
.then((list) => list.filter(Boolean))
}
module.exports = { FEEDS, syncAll, syncFeed, fetchItem, fetchCommentsTree }