CI / build (push) Successful in 2m0s
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).
161 lines
4.4 KiB
JavaScript
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 }
|