# Show HN: Newsroom Self-hosted, AI-ranked meta-aggregator for HN Subreddits etc.

> Source: <https://github.com/SpektrNO/newsroom>
> Published: 2026-08-03 10:20:29+00:00

**Focused news aggregator** — an aggregator of aggregators. It sits on top of Hacker News, Substack, podcasts, Bluesky, Reddit, and other personal feeds you subscribe to, then ranks stories to the topics you care about.

Instead of browsing each source separately, Newsroom pulls recent items into one hybrid-ranked feed (keywords + optional AI), with Save / Dismiss, topic filters, and an in-app Advisor for refining interests.

Personal-first; multi-user-ready data model and APIs. Web UI today; Expo mobile feed next.

- Node.js 20+
[pnpm](https://pnpm.io)9 (`corepack enable`

recommended)- Docker (Postgres + Ollama via Compose —
`docker compose up -d`

; see[docs/ops-local.md](/SpektrNO/newsroom/blob/main/docs/ops-local.md#ollama)) - Optional: host
[Ollama](https://ollama.com)only if you want easier**GPU** access —[docs/ops-local.md](/SpektrNO/newsroom/blob/main/docs/ops-local.md#optional-host-install-for-gpu)

```
cp .env.example .env
cp apps/web/.env.example apps/web/.env.local
# Set BETTER_AUTH_SECRET to ≥32 characters in BOTH files, e.g. openssl rand -base64 32
# Keep shared vars (DB, Ollama, RANK_*) in sync — Next does not read root `.env`.
# See docs/ops-local.md#environment-files

pnpm install
docker compose up -d          # Postgres + Ollama
pnpm db:migrate
pnpm db:seed                  # demo user + HN + Platformer Substack + example topic
# First time: docker exec -it newsroom-ollama ollama pull llama3.2
pnpm --filter @newsroom/web dev
```

Open [http://localhost:3000](http://localhost:3000) — sign up / sign in. Default seed login after `pnpm db:seed`

: ** demo@example.com** /

**newsroom-demo**. Authenticated home is the ranked

**Feed**; use

**Topics**,

**Advisor**,

**Sources**, and

**Settings** in the masthead.

Scores are per user: after seed + ingest + rank, sign in as the seeded demo user (or run `SEED_USER_ID=<your-user-id> pnpm db:seed`

then `pnpm worker:rank`

, or use **Rank latest** on the Feed) to see story rows.

Check health:

```
curl -sS http://localhost:3000/api/health | jq
```

One-shot ingest then rank (requires migrate + seed or your own subscriptions/topics):

```
pnpm worker:ingest            # upserts articles; marks affected users dirty; enqueues per-user rank jobs
pnpm worker:rank              # drains rank jobs for dirty ∩ active users (feed activity in last 30m)
pnpm worker:rank -- --all-dirty  # enqueue/drain all dirty users (ignore activity gate)
pnpm worker:prune-scores      # prune stale scores + articles older than ARTICLE_TTL_DAYS (keeps saved; also runs after Rank latest)
# or: NEWSROOM_WORKER_ONCE=ingest|rank|prune-scores pnpm --filter @newsroom/worker start
```

After ingest + rank, refresh the Feed on [http://localhost:3000](http://localhost:3000) — story rows appear with Save / Dismiss; filter by topic, source, search, or Saved. Topic/source changes mark you dirty and clear unscored feed rows; opening the Feed records activity and enqueues catch-up rank when dirty. **Rank latest** always ranks the signed-in user. **Wipe rankings** clears `new`

/`seen`

scores (Saved/Dismissed stay) and does not auto re-rank.

Long-running worker (claims `ingest`

and `rank`

jobs; ingest cadence ~12 minutes):

```
pnpm --filter @newsroom/worker start
```

| Path | Role |
|---|---|
`apps/web` |
Next.js — auth, editorial feed / topics / sources / settings UI, APIs |
`apps/mobile` |
Expo Router shell (health via api-client) |
`apps/worker` |
Postgres job poller + one-shot ingest/rank CLI |
`packages/db` |
Drizzle schema + migrations (auth, ingest, topics, scores) |
`packages/ai` |
`AiProvider` + `createAiProvider` (Ollama / OpenAI / Google) + keyword/`rankArticleBatch` helpers |
`packages/sources` |
Source adapters: HN, Substack/RSS, podcasts, Bluesky, Reddit |
`packages/api-client` |
Typed client (health, sources, topics, topic-tree, feed) |

`make help`

lists the same shortcuts. Prefer Make for day-to-day; `pnpm`

/ `docker compose`

remain the underlying commands.

| Target | Description |
|---|---|
`make setup` |
Copy `.env` + `apps/web/.env.local` if missing, then `pnpm install` |
`make install` |
Install workspace deps |
`make up` |
Start Postgres; Compose Ollama only if `:11434` is free (else use host Ollama) |
`make up-postgres` |
Postgres only (skip Ollama) |
`make up-gpu` |
Compose with NVIDIA GPU passthrough for Ollama |
`make down` |
Stop Compose services |
`make logs` |
Tail Compose logs |
`make migrate` |
Apply Drizzle migrations |
`make generate` |
Generate Drizzle migrations from schema |
`make seed` |
Demo user + HN + Platformer Substack + example topic |
`make studio` |
Drizzle Studio |
`make web` |
Next.js dev server (:3000) |
`make worker` |
Long-running ingest + rank job poller |
`make ingest` |
One-shot ingest then exit |
`make rank` |
One-shot rank (`RANK_ARGS=-- --all-dirty` optional) |
`make prune` |
One-shot prune stale scores + old articles |
`make test` |
AI + sources unit tests (offline-safe) |
`make test-ai` / `test-web` / `test-worker` / `test-sources` |
Package test suites |
`make typecheck` / `make build` |
Turbo typecheck / build |
`make ollama-pull` |
Pull model into Compose Ollama (`OLLAMA_MODEL=llama3.2` default) |
`make verify` |
Local acceptance script (web must be up) |

| Command | Description |
|---|---|
`pnpm install` |
Install workspace deps |
`docker compose up -d` |
Start Postgres + Ollama |
`docker compose up -d postgres` |
Postgres only (skip Ollama) |
`docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d` |
Start with NVIDIA GPU passthrough for Ollama (needs NVIDIA Container Toolkit on host, see
|

`docker exec -it newsroom-ollama ollama pull llama3.2`

`docker exec -it newsroom-ollama ollama pull llama3.1:8b`

/ `qwen2.5:7b`

`reason`

quality, slower (see [docs/ops-local.md#model-options](/SpektrNO/newsroom/blob/main/docs/ops-local.md#model-options))`pnpm db:generate`

`pnpm db:migrate`

`DATABASE_URL`

`pnpm db:seed`

`AI & infra`

topic`pnpm db:studio`

`pnpm --filter @newsroom/web dev`

/ `pnpm web:dev`

`pnpm --filter @newsroom/web build`

/ `start`

`pnpm --filter @newsroom/worker start`

/ `pnpm worker:start`

`pnpm worker:ingest`

/ `pnpm --filter @newsroom/worker ingest`

`pnpm worker:rank`

/ `pnpm --filter @newsroom/worker rank`

`pnpm worker:prune-scores`

`pnpm --filter @newsroom/mobile start`

/ `pnpm mobile:start`

`pnpm sources:test`

`pnpm worker:test`

`pnpm web:test`

`pnpm --filter @newsroom/ai test`

/ `pnpm ai:test`

`pnpm --filter @newsroom/ai smoke`

/ `pnpm ai:smoke`

`AI_PROVIDER`

(skips if unreachable; `AI_SMOKE=1`

/ `OLLAMA_SMOKE=1`

to require it)`pnpm build`

/ `pnpm typecheck`

`./scripts/verify-scaffold.sh`

Env vars: see [docs/ops-local.md#environment-files](/SpektrNO/newsroom/blob/main/docs/ops-local.md#environment-files) for **which file** loads what. Templates: root [ .env.example](/SpektrNO/newsroom/blob/main/.env.example) (worker/CLI) and

[→](/SpektrNO/newsroom/blob/main/apps/web/.env.example)

`apps/web/.env.example`

`apps/web/.env.local`

(Next / Rank latest). Shared: `DATABASE_URL`

, `BETTER_AUTH_*`

, `OLLAMA_*`

, `RANK_*`

, `AI_TOKEN_*`

, TTLs. Web-only optional: `LANGSEARCH_API_KEY`

(Sources feed search). Root-only: `GITHUB_*`

, `SEED_USER_ID`

, `NEWSROOM_WORKER_ONCE`

. Mobile: `EXPO_PUBLIC_API_URL`

. Ranking tiers: [docs/ops-local.md#ranking-model-tiers](/SpektrNO/newsroom/blob/main/docs/ops-local.md#ranking-model-tiers).

| Method | Path | Notes |
|---|---|---|
`GET/PATCH` |
`/api/settings/rank-model` |
Get / set ranking model tier: `none` | `fast` | `standard` |
`GET/PUT/DELETE` |
`/api/settings/ai-credentials` |
Optional BYOK OpenAI/Google key (encrypted; requires `AI_CREDENTIALS_KEY` ) |

| Method | Path | Notes |
|---|---|---|
`GET/POST` |
`/api/sources` |
List / create (caller’s sources only) |
`PATCH/DELETE` |
`/api/sources/:id` |
Update / delete own source |
`GET` |
`/api/feed-catalog` |
Curated suggested feeds |
`POST` |
`/api/feed-search` |
Discover RSS/Atom URLs via LangSearch (`LANGSEARCH_API_KEY` ); `{ query }` → `{ results }` |

| Method | Path | Notes |
|---|---|---|
`GET` |
`/api/topic-tree` |
Curated catalog `{ version, nodes[] }` (selectable leaves for topic names) |
`GET/POST` |
`/api/topics` |
List / create (caller’s topics only; `name` = catalog leaf label) |
`PATCH/DELETE` |
`/api/topics/:id` |
Update / delete own topic |
`GET` |
`/api/feed?cursor=&topic=&excludeTopic=&source=&status=&limit=` |
Ranked scores; default is `new` /`seen` only; `topic=` includes (OR), `excludeTopic=` excludes; `status=saved` (etc.) filters to that status; returns `rankedCount` / `evaluatedCount` / `articlesCount` pipeline counters |
`POST` |
`/api/feed/:articleId/seen|saved|dismissed` |
Update status; `404` if no score row |

Ranking formulas and job behavior: [docs/decisions/002-hybrid-ranking.md](/SpektrNO/newsroom/blob/main/docs/decisions/002-hybrid-ranking.md). Health, seed, Compose: [docs/ops-local.md](/SpektrNO/newsroom/blob/main/docs/ops-local.md).

| Doc | Purpose |
|---|---|
|

[docs/ops-local.md](/SpektrNO/newsroom/blob/main/docs/ops-local.md)[docs/decisions/001-ingest-url-and-hn.md](/SpektrNO/newsroom/blob/main/docs/decisions/001-ingest-url-and-hn.md)[docs/decisions/002-hybrid-ranking.md](/SpektrNO/newsroom/blob/main/docs/decisions/002-hybrid-ranking.md)[docs/decisions/004-ai-confirmed-topic-membership.md](/SpektrNO/newsroom/blob/main/docs/decisions/004-ai-confirmed-topic-membership.md)[docs/feature-backlog.md](/SpektrNO/newsroom/blob/main/docs/feature-backlog.md)[docs/feature-completed.md](/SpektrNO/newsroom/blob/main/docs/feature-completed.md)[docs/github-workflow.md](/SpektrNO/newsroom/blob/main/docs/github-workflow.md)[docs/contributing.md](/SpektrNO/newsroom/blob/main/docs/contributing.md)Core loop is shipped: auth, multi-source ingest (HN, Substack/RSS, podcasts, Bluesky, Reddit), hybrid rank + Advisor (Ollama / OpenAI / Google, optional BYOK), editorial web UI. Expo feed UI is next per the backlog.

[MIT](/SpektrNO/newsroom/blob/main/LICENSE) © 2026 SpektrNO
