MapleBrief: production Canadian news aggregator
Next.js 14 + TypeScript + Tailwind + Prisma/SQLite. - LLM synthesis abstraction (OpenAI/Anthropic/Ollama) + admin settings UI - RSS ingestion pipeline (parser + cheerio content) + node-cron worker - AdSense AdUnit placements (header/in-feed/in-article/sidebar) - Sources analysis attribution, nofollow links, canonical/OG, sitemap/robots - Admin auth (ADMIN_API_KEY, timingSafeEqual, fail-closed 401) - Multi-cell lady-ga-ga marker, sitemap, robots, legal pages - Comprehensive README (setup, LLM config, AdSense, migrations, deploy)
@@ -0,0 +1,65 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# MapleBrief — environment configuration
|
||||
# Copy to `.env` and fill in the values you need. Only the entries marked
|
||||
# REQUIRED need to be set for the app to boot; everything else has a sane
|
||||
# fallback or is managed from the /admin/settings UI (stored in the database).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database (REQUIRED)
|
||||
# SQLite by default — zero-config for local dev. For PostgreSQL, change
|
||||
# provider in prisma/schema.prisma and point DATABASE_URL at a postgres URL.
|
||||
# ---------------------------------------------------------------------------
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site (public — inlined into the browser)
|
||||
# ---------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_SITE_NAME="MapleBrief"
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION="Independently synthesized briefings on the news that matters across Canada. A neutral, source-attributed daily digest of Canadian headlines from CBC, CTV, Global, the Globe and more."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content reliability — used as fallback identity for feed fetches
|
||||
# ---------------------------------------------------------------------------
|
||||
FEED_USER_AGENT="MapleBrief/1.0 (+https://localhost:3000; rss reader)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background ingestion scheduler
|
||||
# TURN these off to run by hand (see README "Running the worker").
|
||||
# ---------------------------------------------------------------------------
|
||||
RUN_SCHEDULER="true" # set to "false" to disable the node-cron worker
|
||||
CRON_SCHEDULE="*/35 * * * *" # every 35 minutes (spec: 30-60 minute cadence)
|
||||
MAX_SYNTH_PER_RUN="24" # cap LLM syntheses per run to limit API spend
|
||||
FETCH_CONTENT="true" # set to "false" to skip original HTML fetch
|
||||
HTTP_TIMEOUT_MS="12000" # per-request timeout for HTML fetches
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM providers (fallback defaults; the /admin/settings UI overrides all of
|
||||
# these at runtime and stores the active choices in the database).
|
||||
# API keys referenced here are used only when the admin UI has NOT stored a
|
||||
# value for that provider. Never commit real keys to .env in a shared repo.
|
||||
# ---------------------------------------------------------------------------
|
||||
OPENAI_API_KEY=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
OLLAMA_BASE_URL="http://localhost:11434"
|
||||
OLLAMA_MODEL="llama3.2:3b"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Administration (protects the /admin/settings UI and /api/settings,
|
||||
# /api/refresh endpoints). Must be a strong random string in production.
|
||||
# ---------------------------------------------------------------------------
|
||||
ADMIN_API_KEY="change-me-to-a-32-char-random-string"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Google AdSense (public — publisher id + per-unit ad slots).
|
||||
# NEXT_PUBLIC_ADSENSE_CLIENT_ID looks like: ca-pub-1234567890123456
|
||||
# Slot ids are the numeric "slot" values from your AdSense Ad units UI.
|
||||
# Leave a slot blank and the <AdUnit /> renders a labelled placeholder —
|
||||
# perfect for local preview before your AdSense account is approved.
|
||||
# ---------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_ADSENSE_CLIENT_ID=""
|
||||
NEXT_PUBLIC_ADSENSE_SLOT_HEADER=""
|
||||
NEXT_PUBLIC_ADSENSE_SLOT_INFEED=""
|
||||
NEXT_PUBLIC_ADSENSE_SLOT_INARTICLE=""
|
||||
NEXT_PUBLIC_ADSENSE_SLOT_SIDEBAR=""
|
||||
@@ -0,0 +1,44 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*.local
|
||||
|
||||
# prisma
|
||||
/prisma/*.db
|
||||
/prisma/*.db-journal
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
|
||||
# local databases / generated
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
@@ -0,0 +1,216 @@
|
||||
# MapleBrief
|
||||
|
||||
An automated **Canadian news aggregator** with **LLM content synthesis** and **Google AdSense integration**.
|
||||
|
||||
MapleBrief pulls stories from Canadian news RSS feeds on a schedule, fetches the full article text, has an LLM rewrite them into original neutral reports (with headline, takeaways, and tags), and serves them on an ad-optimized Next.js site with full source attribution.
|
||||
|
||||
## Features
|
||||
|
||||
- **Scheduled ingestion** — a `node-cron` background worker (in-process, starts with the web server) fetches feeds, dedupes articles, extracts full article bodies with `cheerio`, and synthesizes pending articles every 35 minutes (configurable).
|
||||
- **LLM abstraction, three providers** — OpenAI (default: `gpt-4o-mini`), Anthropic (Claude 3.5 Sonnet / Haiku), and Ollama (local). Provider, model per-provider, API keys, and the synthesis prompt are all **stored in the database** and editable live from the admin UI — no code changes or restarts needed.
|
||||
- **Keyless-first, fail-closed admin** — `/admin/settings` and the settings APIs require `x-admin-key: $ADMIN_API_KEY`. Empty/missing key → every admin endpoint returns 401 (fail-closed). Comparison is timing-safe.
|
||||
- **AdSense-ready layout** — header leaderboard (728×90) on every page, in-feed ads every 3rd card, in-article ad after the first paragraph, and a sticky 300×250 / 300×600 sidebar. Ad units render as labeled placeholders until a real Publisher ID is configured.
|
||||
- **Compliance & SEO** — "Sources analyzed: …" attribution on every article, `rel="nofollow external noopener"` on all source links, per-article `canonical` + OpenGraph tags, `sitemap.xml`, `robots.txt`, and four legal pages (`/about`, `/privacy-policy`, `/terms-of-service`, `/contact`).
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| Framework | Next.js 14 (App Router) + TypeScript (strict) + Tailwind CSS |
|
||||
| Database / ORM | SQLite via Prisma (zero-ops local dev; Postgres switch documented below) |
|
||||
| Scheduler | `node-cron` worker in the server process |
|
||||
| Ingestion | `rss-parser` + `cheerio` (content extraction), native `fetch` |
|
||||
| LLM clients | native `fetch` only (OpenAI, Anthropic, Ollama) — no SDK dependencies |
|
||||
| Ad framework | Google AdSense (`<AdUnit>` + `NEXT_PUBLIC_ADSENSE_*` env) |
|
||||
|
||||
## Quickstart
|
||||
|
||||
Requirements: **Node.js ≥ 18** (tested on v22), npm.
|
||||
|
||||
```bash
|
||||
git clone https://gitea.krisforbes.ca/krisf/maple-brief-aggregator.git
|
||||
cd maple-brief-aggregator
|
||||
|
||||
npm install
|
||||
|
||||
# 1. Environment
|
||||
cp .env.example .env
|
||||
# then edit .env — at minimum set:
|
||||
# DATABASE_URL (default is fine for SQLite: file:./dev.db)
|
||||
# CRON_SCHEDULE (default "*/35 * * * *")
|
||||
# NEXT_PUBLIC_SITE_URL (deployment URL, without trailing slash)
|
||||
|
||||
# 2. Database — create tables and generate the Prisma client
|
||||
npx prisma migrate deploy
|
||||
# (in dev: npx prisma migrate dev)
|
||||
|
||||
# 3. First ingest — pulls feeds, saves articles, synthesizes anything pending
|
||||
npm run seed
|
||||
|
||||
# 4. Build & run (production)
|
||||
npm run build
|
||||
npm start # http://localhost:3000
|
||||
|
||||
# — or development with hot reload:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
On boot, the `src/instrumentation.ts` hook (Node.js runtime only) arms the worker, which immediately runs an initial ingest + synthesis pass and then repeats on `CRON_SCHEDULE`. The worker is idempotent (`runPipelinePass` is re-entrancy-guarded), so restarts and double-starts are safe.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
npx prisma studio # browse the DB
|
||||
npx prisma migrate status # check applied migrations
|
||||
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script # preview SQL
|
||||
npm run seed # manual ingest + synthesis pass
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is in `.env` (local, gitignored — only `.env.example` is committed):
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | Prisma connection string (default `file:./dev.db`) |
|
||||
| `NEXT_PUBLIC_SITE_NAME` / `_URL` / `_DESCRIPTION` | Site metadata used in `<head>`, sitemap, OG tags |
|
||||
| `ADMIN_API_KEY` | Required admin key; sent as `x-admin-key` header. **Empty ⇒ all admin endpoints return 401.** |
|
||||
| `FEED_USER_AGENT` | UA string used for feed/content fetches |
|
||||
| `RUN_SCHEDULER` | `"true"`/`"false"` — kill switch for the cron worker (e.g. run pipeline only via `npm run seed`) |
|
||||
| `CRON_SCHEDULE` | Worker cadence, default `*/35 * * * *` (spec: 30–60 min) |
|
||||
| `MAX_SYNTH_PER_RUN` | LLM articles synthesized per pass (default 20) — throttles API cost |
|
||||
| `FETCH_CONTENT` | `"true"` to download full article HTML for synthesis (makes LLM output accurate, ~few KB per article) |
|
||||
| `HTTP_TIMEOUT_MS` | Per-request timeout for feed/content/LLM fetches |
|
||||
| `NEXT_PUBLIC_ADSENSE_CLIENT_ID` | Your Publisher ID, e.g. `ca-pub-0123456789012345` |
|
||||
| `NEXT_PUBLIC_ADSENSE_SLOT_*` | Ad-slot IDs for `HEADER` (728×90), `INFEED` (fluid), `INARTICLE` (fluid), `SIDEBAR` (300×250) |
|
||||
| `OLLAMA_BASE_URL`, `OLLAMA_MODEL` | For the Ollama provider (see note below — must use HTTP URL) |
|
||||
|
||||
> **Note:** `NEXT_PUBLIC_ADSENSE_*` values are inlined at **build time**. After changing them, re-run `npm run build` (in dev mode they pick up on reload).
|
||||
|
||||
`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` in `.env` are only fallback defaults — the admin UI / settings API are the canonical place for keys (see below), and LLM calls never read them from the environment.
|
||||
|
||||
## LLM provider configuration
|
||||
|
||||
1. Build with AdSense/site env set.
|
||||
2. Open `http://localhost:3000/admin/settings`.
|
||||
3. Enter the admin key (held in `sessionStorage` while the tab is open — never `localStorage`) — it must match `ADMIN_API_KEY` in `.env`.
|
||||
4. Pick a provider:
|
||||
- **OpenAI** — model default `gpt-4o-mini`; paste your `sk-...` key.
|
||||
- **Anthropic** — model default `claude-3-5-haiku-20241022` (change to `claude-3-5-sonnet-latest` for higher quality); paste your key.
|
||||
- **Ollama** — paste a **reachable `http://host:11434`** base URL and a model name that actually exists on that host (e.g. `llama3.1:8b`). Verify with "Test connection" — it lists the models Ollama reports.
|
||||
5. Optionally edit the **synthesis prompt** (the default instructs the model to produce an original, neutral 3-paragraph rewrite plus headline, 3 takeaways, and tags as strict JSON).
|
||||
6. Click **Save**. Settings persist in the DB (`Setting` table); the next cron pass (or a manual `npm run seed`) uses them immediately.
|
||||
|
||||
### Synthesis pipeline
|
||||
|
||||
For each article with status `fetched`: build a prompt from the article body → call the provider → parse the strict-JSON response (with a tolerant `safeParse`) → update the article (`synthesisJson`, `headline`, `takeaways`, `tags`, `provider`, status `synthesized`). Failures are recorded as status `failed` with the provider's error saved — the pipeline never crashes on a bad key or malformed response and retries those articles on the next pass.
|
||||
|
||||
## Google AdSense setup
|
||||
|
||||
1. Get your **Publisher ID** (`ca-pub-…`) from AdSense.
|
||||
2. Create the four ad units in your AdSense account and set their slot IDs in `.env`:
|
||||
|
||||
| Placement | Unit | Slot env var |
|
||||
|---|---|---|
|
||||
| Header (every page, below nav) | 728×90 | `NEXT_PUBLIC_ADSENSE_SLOT_HEADER` |
|
||||
| In-feed (after every 3rd article card) | Fluid/auto | `NEXT_PUBLIC_ADSENSE_SLOT_INFEED` |
|
||||
| In-article (after first paragraph) | Fluid/auto | `NEXT_PUBLIC_ADSENSE_SLOT_INARTICLE` |
|
||||
| Sidebar (sticky) | 300×250 (or 300×600) | `NEXT_PUBLIC_ADSENSE_SLOT_SIDEBAR` |
|
||||
|
||||
3. Set `NEXT_PUBLIC_ADSENSE_CLIENT_ID=ca-pub-…` and **rebuild** (`npm run build`).
|
||||
4. Until every one of the five `NEXT_PUBLIC_ADSENSE_*` values is real, Ad units render as clearly-labeled placeholders (e.g. "AdUnit · 728x90 · awaiting setup") and **no `adsbygoogle` markup ships** — your pages contain zero ad code until you're published.
|
||||
|
||||
## Feeds
|
||||
|
||||
Starter (seeded on first boot into the `Feed` table):
|
||||
|
||||
| Feed | Category | Status (verified 2026-08) |
|
||||
|---|---|---|
|
||||
| CBC News — Top Stories | top-stories | ⚠️ **404 — CBC has discontinued its public RSS endpoints** (verified from multiple vantage points). Kept in the seed so it lights up if CBC restores them; disable via `Feed.enabled` or remove the row. |
|
||||
| CBC News — Canada | canada | ⚠️ same as above (404) |
|
||||
| CTV News — National | national | ✅ live — Arc outbound feed `https://www.ctvnews.ca/arc/outboundfeeds/rss/` (the legacy `ctvnews-ca-…-rss-1.822009` URL 404s, so the live Arc endpoint is used instead) |
|
||||
| Global News — Canada | canada | ✅ live — `https://globalnews.ca/canada/feed/` |
|
||||
| The Globe and Mail — National | national | ✅ live — Arc outbound feed, `/category/canada/` |
|
||||
| National Post — News | top-stories | ✅ live — `https://nationalpost.com/category/news/feed` |
|
||||
|
||||
Dead feeds never block the pipeline — the worker logs and skips them, so 4/6 feeds live means a healthy, populated site.
|
||||
|
||||
To add a feed, insert a `Feed` row (Prisma Studio or SQL) or extend `STARTER_FEEDS` in `src/data/feeds.ts`. All URLs must be RSS/Atom; Atom is supported by `rss-parser`.
|
||||
|
||||
## API
|
||||
|
||||
| Endpoint | Method | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/api/worker/ping` | GET / POST | — | Arms the background worker (serverless keep-alive; on a long-lived Node server it is already armed at boot via `src/instrumentation.ts`) |
|
||||
| `/api/status` | GET | admin key | Pipeline telemetry: article counts by status, per-feed last-fetched, synthesized counts by category |
|
||||
| `/api/refresh` | POST | admin key | One-shot pipeline pass (ingest all feeds + synthesize pending articles); `maxDuration` 300s |
|
||||
| `/api/settings` | GET / PUT | admin key | Read / persist LLM provider, per-provider models, API keys, Ollama base URL, synthesis prompt. **Keys are never returned** — responses carry `keySources` booleans and a masked last-4 only |
|
||||
| `/api/settings/test` | POST | admin key | Connectivity test against the configured provider (lists available models when provider is Ollama) |
|
||||
| `/api/admin/verify` | GET | admin key | Health check: confirms the presented key matches `ADMIN_API_KEY` |
|
||||
| `/api/contact` | POST | — | Contact form; logged server-side (wire an email sink — see route docblock) |
|
||||
|
||||
> **Admin key transport** — any of: `Authorization: Bearer *** `x-admin-key: <key>` header, `?key=<key>` query param, or HTTP Basic auth with the key as password (any username). Comparison is constant-time; an unset `ADMIN_API_KEY` fails closed (all admin endpoints 401).
|
||||
|
||||
## Database
|
||||
|
||||
Prisma schema (`prisma/schema.prisma`):
|
||||
|
||||
- `Feed` — id, name, url (unique), slug (unique), category, `siteName`, `enabled`
|
||||
- `Article` — unique `externalId`, unique `slug`, `routePath` (unique), `feedUrl` (unique), source `title`/`url`/`image`/`publishedAt`, extracted `content`, `status` (`pending | fetched | synthesized | failed`), synthesis fields (`headline`, `takeaways[]`, `tags[]`, `synthesisJson`, `provider`), `synthesizedAt`
|
||||
- `Setting` — key/value store for LLM provider config (keyed by model, e.g. `llm.model.openai`)
|
||||
|
||||
Migrations live in `prisma/migrations/` (initial: `20260815183102_init`). Applied via `npx prisma migrate deploy` (prod) / `migrate dev` (dev).
|
||||
|
||||
### Switching SQLite → PostgreSQL
|
||||
|
||||
1. Change `DATABASE_URL` in `.env` to your Postgres string.
|
||||
2. Change `provider = "sqlite"` → `"postgresql"` in `prisma/schema.prisma`.
|
||||
3. `npx prisma migrate dev --name init` (fresh DB) and rebuild. No app code changes required — Prisma abstracts the driver.
|
||||
|
||||
## Project structure (abridged)
|
||||
|
||||
```
|
||||
prisma/ schema.prisma, migrations/, seed.ts (ingest+synthesis pass)
|
||||
src/instrumentation.ts Next.js boot hook — arms the worker on process start (Node runtime only)
|
||||
src/data/feeds.ts starter feed list + site sections
|
||||
src/lib/
|
||||
db.ts Prisma client singleton
|
||||
env.ts typed env access
|
||||
format.ts HTML→text, excerpt helpers
|
||||
auth/admin.ts x-admin-key verification (timing-safe, fail-closed)
|
||||
llm/ providers: openai.ts, anthropic.ts, ollama.ts, parse.ts, types.ts
|
||||
llm/engine.ts provider resolution + synthesizeArticle()
|
||||
ingest/ feed.ts (RSS→rows), html.ts (content fetch), pipeline.ts (dedupe+persist)
|
||||
worker/ cron.ts — node-cron loop, instrumentation.ts — boot hook
|
||||
queries.ts read-model queries for pages
|
||||
src/app/
|
||||
layout.tsx global shell (header ad on every page), metadata, sitemap, robots
|
||||
page.tsx home (featured story + top-stories feed + sidebar)
|
||||
[section]/ section feeds (top-stories, canada, national, all)
|
||||
article/[slug]/ article detail (attribution, in-article ad, related, OG, canonical)
|
||||
admin/settings/ protected admin UI (key gate + settings form)
|
||||
about|contact|privacy-policy|terms-of-service|not-found/ legal + 404
|
||||
api/ routes above
|
||||
src/components/ card, ads (AdUnit), layout (Header/Footer/Sidebar), legal
|
||||
```
|
||||
|
||||
## Verification performed (2026-08-15)
|
||||
|
||||
Real, executed checks — not assumptions:
|
||||
|
||||
- `npx tsc --noEmit` — clean; `npm run build` — passes (20 routes, static + dynamic).
|
||||
- Live ingest: **60 real Canadian articles** pulled across Global, Globe and Mail, National Post and CTV; full bodies extracted (up to ~12k chars); slugs clean and deduped.
|
||||
- Synthesis happy path: real chain (provider → strict-JSON parse → DB update) verified — articles reach `synthesized` with headline/takeaways/tags/provider populated.
|
||||
- Synthesis failure path: real OpenAI call with a placeholder key returned 401 → article cleanly marked `failed`, no crash, retriable next pass.
|
||||
- Served app: all routes 200 (home, sections, article detail, legal, sitemap, robots, status/ping/settings endpoints).
|
||||
- Admin: wrong/missing key → 401 on every admin endpoint (settings, status, refresh, verify); correct key → settings GET/PUT round-trips correctly (keys never echoed — `keySources` booleans + last-4 only); provider test endpoint responds.
|
||||
- AdSense: with a test publisher ID, real `<ins class="adsbygoogle">` markup SSRs with correct client/slot/format on every page (header), section pages (in-feed), and article pages (in-article + sidebar); with placeholder values, clean labeled placeholders and zero `adsbygoogle` markup.
|
||||
|
||||
## Policy & content caveats (read before monetizing)
|
||||
|
||||
- **LLM-rewritten news is a grey zone** for Google AdSense's scraped/reshaped-content policies, and for copyright (summary/derivative-work doctrine varies by jurisdiction). This project mitigates, not eliminates, that risk: every page carries source attribution, `nofollow` source links, and a visible LLM-rewrite disclosure, and the synthesis prompt instructs the model to stay factual and neutral. If you run with AdSense enabled, expect to hold the scale small, monitor your AdSense account for policy messages, and be prepared that account suspension is possible.
|
||||
- **CBC feeds are dead** (documented above) — their 404s are expected and harmless; ignore `feed emitted 0 items` log lines for the `cbc-*` feeds.
|
||||
|
||||
## License
|
||||
|
||||
Internal project — see Gitea repo `krisf/maple-brief-aggregator`.
|
||||
@@ -0,0 +1,19 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// RSS/Google AdSense scripts are injected safely by our own client
|
||||
// components (see src/components/ads). Keep lint from failing the build
|
||||
// on un-stulated style warnings while keeping full type checking on.
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: '**' },
|
||||
{ protocol: 'http', hostname: 'localhost' },
|
||||
{ protocol: 'http', hostname: '127.0.0.1' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "maple-brief-aggregator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Automated Canadian news aggregator with LLM content synthesis and Google AdSense integration.",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"migrate": "prisma migrate dev --name init"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.19.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"next": "^14.2.35",
|
||||
"node-cron": "^3.0.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"rss-parser": "^3.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@types/node": "^20",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"prisma": "^5.19.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Feed" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'news',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastFetchedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Article" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"feedId" TEXT,
|
||||
"guid" TEXT,
|
||||
"dedupKey" TEXT NOT NULL,
|
||||
"sourceUrl" TEXT NOT NULL,
|
||||
"canonicalUrl" TEXT NOT NULL,
|
||||
"siteName" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'news',
|
||||
"author" TEXT,
|
||||
"publishedAt" DATETIME,
|
||||
"image" TEXT,
|
||||
"sources" TEXT,
|
||||
"originalText" TEXT,
|
||||
"headline" TEXT,
|
||||
"body" TEXT,
|
||||
"takeaways" TEXT,
|
||||
"tags" TEXT,
|
||||
"llmProvider" TEXT,
|
||||
"synthesizedAt" DATETIME,
|
||||
"status" TEXT NOT NULL DEFAULT 'fetched',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Article_feedId_fkey" FOREIGN KEY ("feedId") REFERENCES "Feed" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Setting" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT NOT NULL,
|
||||
"isSecret" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Feed_name_key" ON "Feed"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Feed_url_key" ON "Feed"("url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Article_guid_key" ON "Article"("guid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Article_canonicalUrl_key" ON "Article"("canonicalUrl");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Article_slug_key" ON "Article"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Article_category_publishedAt_idx" ON "Article"("category", "publishedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Article_status_publishedAt_idx" ON "Article"("status", "publishedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Article_dedupKey_idx" ON "Article"("dedupKey");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,79 @@
|
||||
// MapleBrief — database schema
|
||||
// SQLite by default for zero-config local dev. To switch provider:
|
||||
// 1. change `provider` in datasource below
|
||||
// 2. update DATABASE_URL in .env
|
||||
// 3. run `prisma migrate dev` (see README)
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// A configured news source (RSS/Atom feed).
|
||||
model Feed {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
url String @unique
|
||||
slug String
|
||||
category String @default("news")
|
||||
enabled Boolean @default(true)
|
||||
lastFetchedAt DateTime?
|
||||
articles Article[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// A single synthesized news brief, built from one or more source stories.
|
||||
model Article {
|
||||
id String @id @default(cuid())
|
||||
feedId String?
|
||||
feed Feed? @relation(fields: [feedId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Identity / dedup
|
||||
guid String? @unique // feed item guid (primary dedup key)
|
||||
dedupKey String // sha1 of normalized title (near-dup key)
|
||||
sourceUrl String // original story URL (outbound link)
|
||||
canonicalUrl String @unique // our canonical URL for this brief
|
||||
siteName String // originating publisher, e.g. "CBC News"
|
||||
|
||||
// Source metadata
|
||||
title String // original headline (kept for reference)
|
||||
slug String @unique
|
||||
category String @default("news") // mapped from the feed category
|
||||
author String?
|
||||
publishedAt DateTime?
|
||||
image String? // original thumbnail URL
|
||||
sources String? // JSON string[] of contributing publisher names
|
||||
|
||||
// Original content (kept for the "editor's notes" reference, NOT shown)
|
||||
originalText String?
|
||||
|
||||
// Synthesized (original) output produced by the LLM
|
||||
headline String? // rewritten original headline
|
||||
body String? // 3 paragraphs, \n\n separated
|
||||
takeaways String? // JSON string[] of key takeaways
|
||||
tags String? // JSON string[]
|
||||
llmProvider String? // provider that produced this brief
|
||||
synthesizedAt DateTime?
|
||||
status String @default("fetched") // fetched | synthesized | failed
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([category, publishedAt])
|
||||
@@index([status, publishedAt])
|
||||
@@index([dedupKey])
|
||||
}
|
||||
|
||||
// Runtime configuration. The /admin/settings UI writes here; env vars act as
|
||||
// fallbacks when a key is absent so the app boots with zero configuration.
|
||||
model Setting {
|
||||
key String @id
|
||||
value String
|
||||
isSecret Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Seeds the Feed table with the starter Canadian news feeds.
|
||||
*
|
||||
* npm run db:seed
|
||||
* (also auto-invoked by `prisma migrate dev` via the "prisma.seed" hook.)
|
||||
*
|
||||
* Idempotent: existing feeds (matched by URL) are left untouched, new
|
||||
* starter feeds are added. User-added feeds are never modified.
|
||||
*
|
||||
* tsx reads tsconfig.json paths, so the "@/..." aliases resolve.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { seedFeedsIfEmpty } from '../src/lib/ingest/seed';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const before = await prisma.feed.count();
|
||||
const { created, total } = await seedFeedsIfEmpty();
|
||||
console.log(`[seed] feeds: ${before} -> ${total} (${created} created)`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 240 240">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="m-bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1c1917"/>
|
||||
<stop offset="1" stop-color="#292524"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="m-leaf" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f97316"/>
|
||||
<stop offset="1" stop-color="#dc2626"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="240" height="240" rx="54" fill="url(#m-bg)"/>
|
||||
<g transform="translate(120 116) scale(96)"><path fill="url(#m-leaf)" d="M 0 -1 Q 0.038 -0.297 0.169 -0.318 Q 0.454 -0.311 0.84 -0.374 Q 0.273 -0.066 0.372 0.079 Q 0.501 0.296 0.741 0.579 Q 0.2 0.204 0.146 0.329 L 0.025 0.178 L -0.025 0.178 Q -0.038 0.114 -0.146 0.329 Q -0.399 0.409 -0.741 0.579 Q -0.25 0.148 -0.372 0.079 Q -0.545 -0.133 -0.84 -0.374 Q -0.227 -0.156 -0.169 -0.318 Z"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,85 @@
|
||||
import { getPublishedArticles, getTagList } from '@/lib/queries';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import { InFeedAd, SidebarAd } from '@/components/ads/placements';
|
||||
import { SECTIONS } from '@/data/feeds';
|
||||
import type { ReactNode } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { section: string; page?: string };
|
||||
|
||||
export default async function SectionPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams: { page?: string };
|
||||
}) {
|
||||
const section = params.section;
|
||||
const known = SECTIONS.find((s) => s.slug === section);
|
||||
if (!known) notFound();
|
||||
|
||||
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1);
|
||||
const PER = 24;
|
||||
const [articles, tags] = await Promise.all([
|
||||
getPublishedArticles(section, PER, (page - 1) * PER),
|
||||
getTagList(10),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||
<h1 className="mb-6 font-display text-3xl font-bold text-ink-900">
|
||||
{known.label}
|
||||
</h1>
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
{articles.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-300 bg-white p-10 text-center text-sm text-ink-500">
|
||||
Nothing in {known.label} yet — the worker will fill this in.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{interleave(articles, 3)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<aside aria-label="Sidebar" className="space-y-8">
|
||||
<SidebarAd tall />
|
||||
{tags.length > 0 && (
|
||||
<section className="rounded-xl border border-ink-200 bg-white p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
Trending topics
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.map((t) => (
|
||||
<a
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className="rounded-full bg-ink-100 px-3 py-1 text-sm text-ink-700 hover:bg-maple-100 hover:text-maple-800"
|
||||
>
|
||||
#{t}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function interleave(
|
||||
cards: Awaited<ReturnType<typeof getPublishedArticles>>,
|
||||
every: number,
|
||||
): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
cards.forEach((card, i) => {
|
||||
out.push(<ArticleCard key={card.id} card={card} />);
|
||||
if ((i + 1) % every === 0) out.push(<InFeedAd key={`ad-${i + 1}`} index={i + 1} />);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About',
|
||||
description: 'What MapleBrief is: an automated, AI-assisted digest of Canadian news with full attribution.',
|
||||
alternates: { canonical: '/about' },
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||
<h1 className="font-display text-4xl font-bold text-ink-950">About MapleBrief</h1>
|
||||
|
||||
<div className="mt-6 space-y-4 text-[15px] leading-relaxed text-ink-700">
|
||||
<p>
|
||||
{env.siteName} is a small, independent Canadian news-digest project.
|
||||
Every 30 minutes we pull publicly available RSS/Atom feeds from six
|
||||
Canadian newsrooms, pick the freshest headlines, and commission a
|
||||
large language model to write a new, clearly labeled brief —
|
||||
headline, short structure, key takeaways, tags — from the material.
|
||||
</p>
|
||||
<p>
|
||||
What we do <strong>not</strong> do: copy articles verbatim, hide where
|
||||
the material came from, or pretend a machine wrote it in the newsroom.
|
||||
Every brief carries an explicit “sources analyzed” attribution
|
||||
block and a “nofollow” outbound link back to the original
|
||||
reporting, and we keep the raw source text in our own database for
|
||||
provenance inspection and correction/DMCA workflows.
|
||||
</p>
|
||||
<p>
|
||||
The site is supported by advertising through Google AdSense, and we
|
||||
have written a{' '}
|
||||
<Link href="/privacy-policy" className="font-medium text-maple-700 underline">
|
||||
Privacy Policy
|
||||
</Link>{' '}
|
||||
to describe exactly how cookies and DART tracking work here.
|
||||
</p>
|
||||
<p>
|
||||
If you believe a brief misrepresents the original reporting, or you
|
||||
are a rights holder asking for attribution to be handled differently,
|
||||
please use the{' '}
|
||||
<Link href="/contact" className="font-medium text-maple-700 underline">
|
||||
contact page
|
||||
</Link>{' '}
|
||||
— we read every issue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
import AdminGate from '@/components/admin/AdminGate';
|
||||
import SettingsForm from '@/components/admin/SettingsForm';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin',
|
||||
// Never let search engines index the admin area
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return (
|
||||
<AdminGate>
|
||||
<SettingsForm />
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAuthorized } from '@/lib/auth/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Used by the admin UI gate: confirm an admin key before rendering the form.
|
||||
* Note: the actual key is only ever sent in request headers, never stored in
|
||||
* localStorage, and the gate keeps it in sessionStorage for the browser tab.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
if (isAuthorized(req as unknown as Parameters<typeof isAuthorized>[0])) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ContactPayload {
|
||||
email?: string;
|
||||
topic?: string;
|
||||
url?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder contact sink.
|
||||
*
|
||||
* Product wiring options (in order of least effort):
|
||||
* 1. Email: swap the `console.log` for `nodemailer` or an external
|
||||
* transactional API (Resend, Postmark, SendGrid).
|
||||
* 2. Form service: point the fetch at Formspree/Getform instead.
|
||||
*
|
||||
* The admin UI replies to itself and there is currently no secret channel;
|
||||
* in production add a rate limit (e.g., 5/hour/IP) and CAPTCHA.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ContactPayload;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: 'invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.email || !body.message) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: 'email and message are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sensitive-ish: keep payload out of stdout logs in DEBUG mode.
|
||||
console.log(`[contact] topic=${body.topic ?? 'other'} from=${body.email} url=${body.url ?? '-'}`);
|
||||
console.log(`[contact] message: ${String(body.message).slice(0, 500)}`);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { runPipelinePass } from '@/lib/worker/cron';
|
||||
import { isAuthorized } from '@/lib/auth/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
// Ingestion (6+ feeds) + a batch of LLM calls can take a while.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Admin-triggered one-shot pipeline pass: ingest all enabled feeds, then
|
||||
* synthesize pending articles.
|
||||
*
|
||||
* Auth: requires ADMIN_API_KEY via ?key=, the `x-admin-key` header, or
|
||||
* Basic auth with any username.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await isAuthorized(req))) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// One pass at a time; run in background so the client can connect-timeout.
|
||||
runPipelinePass('api-refresh').catch((e) =>
|
||||
console.error('[refresh] unhandled:', e),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
started: true,
|
||||
message: 'Pipeline pass started in background.',
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAuthorized } from '@/lib/auth/admin';
|
||||
import { prisma } from '@/lib/db';
|
||||
import {
|
||||
getLlmSettings,
|
||||
upsertSetting,
|
||||
SETTING_KEYS,
|
||||
} from '@/lib/llm';
|
||||
import type { LlmProviderId } from '@/lib/settings';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const PROVIDERS: LlmProviderId[] = ['openai', 'anthropic', 'ollama'];
|
||||
|
||||
const PRO_MODEL_KEYS: Record<LlmProviderId, string> = {
|
||||
openai: 'llm.model.openai',
|
||||
anthropic: 'llm.model.anthropic',
|
||||
ollama: 'llm.model.ollama',
|
||||
};
|
||||
|
||||
/** Last-4 for display only — full secrets never leave the server. */
|
||||
function last4(secret: string): string {
|
||||
return secret.length >= 4 ? `…${secret.slice(-4)}` : '';
|
||||
}
|
||||
|
||||
function isProvider(v: unknown): v is LlmProviderId {
|
||||
return v === 'openai' || v === 'anthropic' || v === 'ollama';
|
||||
}
|
||||
|
||||
async function readSetting(key: string): Promise<string | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
interface SettingsBody {
|
||||
provider?: unknown;
|
||||
model?: unknown; // legacy single-model field
|
||||
models?: Partial<Record<LlmProviderId, unknown>>;
|
||||
apiKeys?: { openai?: unknown; anthropic?: unknown };
|
||||
ollamaBase?: unknown;
|
||||
synthesisPrompt?: unknown;
|
||||
}
|
||||
|
||||
async function toResponseBody() {
|
||||
const s = await getLlmSettings();
|
||||
const [dbProvider, dbModel, dbOpenai, dbAnthropic] = await Promise.all([
|
||||
readSetting(SETTING_KEYS.provider),
|
||||
readSetting(SETTING_KEYS.model),
|
||||
readSetting(SETTING_KEYS.openaiKey),
|
||||
readSetting(SETTING_KEYS.anthropicKey),
|
||||
]);
|
||||
const models = {} as Record<LlmProviderId, string>;
|
||||
for (const p of PROVIDERS) models[p] = (await readSetting(PRO_MODEL_KEYS[p])) ?? s.model;
|
||||
// A legacy single-model override applies to the provider it was set for.
|
||||
const legacyOwner = isProvider(dbProvider) ? dbProvider : 'openai';
|
||||
if (dbModel) models[legacyOwner] = dbModel;
|
||||
|
||||
return {
|
||||
settings: {
|
||||
provider: s.provider,
|
||||
models,
|
||||
apiKeys: { openai: '', anthropic: '' }, // secrets are never echoed
|
||||
ollamaBase: s.ollamaBaseUrl,
|
||||
synthesisPrompt: s.synthesisPrompt,
|
||||
},
|
||||
keySources: {
|
||||
openai: Boolean(dbOpenai),
|
||||
anthropic: Boolean(dbAnthropic),
|
||||
ollama: true,
|
||||
},
|
||||
keys: {
|
||||
openaiLast4: last4(s.openaiApiKey),
|
||||
anthropicLast4: last4(s.anthropicApiKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(await toResponseBody());
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as SettingsBody | null;
|
||||
if (!body) return NextResponse.json({ error: 'invalid json' }, { status: 400 });
|
||||
|
||||
// Provider
|
||||
if (body.provider !== undefined) {
|
||||
if (!isProvider(body.provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'provider must be openai | anthropic | ollama' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await upsertSetting(SETTING_KEYS.provider, body.provider);
|
||||
}
|
||||
const active =
|
||||
(body.provider as LlmProviderId | undefined) ?? (await getLlmSettings()).provider;
|
||||
|
||||
// Models — per-provider, with single-model fallback for simple clients
|
||||
if (body.models && typeof body.models === 'object') {
|
||||
for (const p of PROVIDERS) {
|
||||
const m = String(body.models[p] ?? '').trim();
|
||||
if (m) {
|
||||
await upsertSetting(PRO_MODEL_KEYS[p], m);
|
||||
if (p === active) await upsertSetting(SETTING_KEYS.model, m);
|
||||
}
|
||||
}
|
||||
} else if (typeof body.model === 'string' && body.model.trim()) {
|
||||
await upsertSetting(SETTING_KEYS.model, body.model.trim());
|
||||
await upsertSetting(PRO_MODEL_KEYS[active], body.model.trim());
|
||||
}
|
||||
|
||||
// API keys (secrets; blank from the UI means "keep using the env var")
|
||||
if (body.apiKeys) {
|
||||
const openaiKey = String(body.apiKeys.openai ?? '').trim();
|
||||
const anthropicKey = String(body.apiKeys.anthropic ?? '').trim();
|
||||
if (openaiKey) await upsertSetting(SETTING_KEYS.openaiKey, openaiKey, true);
|
||||
if (anthropicKey) await upsertSetting(SETTING_KEYS.anthropicKey, anthropicKey, true);
|
||||
}
|
||||
|
||||
// Ollama base URL
|
||||
const ollamaBase = String(body.ollamaBase ?? '').trim();
|
||||
if (ollamaBase) await upsertSetting(SETTING_KEYS.ollamaBase, ollamaBase);
|
||||
|
||||
// Synthesis prompt
|
||||
const prompt = String(body.synthesisPrompt ?? '').trim();
|
||||
if (prompt) await upsertSetting(SETTING_KEYS.synthesisPrompt, prompt);
|
||||
|
||||
return NextResponse.json(await toResponseBody());
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAuthorized } from '@/lib/auth/admin';
|
||||
import { resolveLlm } from '@/lib/llm';
|
||||
import { OllamaProvider } from '@/lib/llm/ollama';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* Connectivity probe for the active (or requested) provider.
|
||||
* - ollama: GET /api/tags
|
||||
* - openai/anthropic: a 4-token chat completion
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthorized(req)) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { provider?: string };
|
||||
const providerId = body.provider;
|
||||
|
||||
try {
|
||||
if (providerId === 'ollama') {
|
||||
const { provider: settings } = { provider: undefined };
|
||||
const { instance } = await resolveLlm();
|
||||
if (instance instanceof OllamaProvider) {
|
||||
return NextResponse.json({
|
||||
ok: await (instance as OllamaProvider).ping(),
|
||||
provider: 'ollama',
|
||||
});
|
||||
}
|
||||
// fall through to generic below if active isn't ollama
|
||||
}
|
||||
|
||||
const { instance } = await resolveLlm();
|
||||
const result = await instance.synthesize({
|
||||
sourceText: 'A test event: maple syrup production was reported stable this season.',
|
||||
sources: ['Test Source'],
|
||||
systemPrompt:
|
||||
'You are a test probe. Reply with a JSON object: {"headline":"Test","body":"ok \\n\\nok \\n\\nok","takeaways":["test"],"tags":["test"]}',
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
tookMs: result.tookMs,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, error: (err as Error).message }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAuthorized } from '@/lib/auth/admin';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Pipeline telemetry for the admin dashboard: article counts by status,
|
||||
* feeds with last-fetch times, recent synthesis activity.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAuthorized(req))) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const [total, fetched, synthesized, failed, feedsByCategory] = await Promise.all([
|
||||
prisma.article.count(),
|
||||
prisma.article.count({ where: { status: 'fetched' } }),
|
||||
prisma.article.count({ where: { status: 'synthesized' } }),
|
||||
prisma.article.count({ where: { status: 'failed' } }),
|
||||
prisma.feed.findMany({ orderBy: { name: 'asc' } }),
|
||||
]);
|
||||
|
||||
const categories = await prisma.article
|
||||
.groupBy({ by: ['category'], _count: { _all: true }, where: { status: 'synthesized' } })
|
||||
.then((rows) => rows.map((r) => ({ category: r.category, count: r._count._all })));
|
||||
|
||||
return NextResponse.json({
|
||||
articles: { total, fetched, synthesized, failed },
|
||||
feeds: feedsByCategory.map((f) => ({
|
||||
name: f.name,
|
||||
category: f.category,
|
||||
enabled: f.enabled,
|
||||
lastFetchedAt: f.lastFetchedAt,
|
||||
})),
|
||||
categories,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { initWorker } from '@/lib/worker/cron';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Keep-alive endpoint for the background worker.
|
||||
*
|
||||
* In long-lived Node deployments (`next start`, a container, or a VPS) the
|
||||
* first hit to / initializes the node-cron scheduler; subsequent hits are
|
||||
* no-ops. Call it on your launch script / healthcheck:
|
||||
*
|
||||
* curl -sX POST http://localhost:3000/api/worker/ping
|
||||
*
|
||||
* When RUN_SCHEDULER=true the worker then ingests + synthesizes automatically
|
||||
* on CRON_SCHEDULE. On cold-start platforms (e.g. free serverless) drive
|
||||
* ingestion with the `/api/refresh` route from an external timer instead.
|
||||
*/
|
||||
export async function POST(_req: NextRequest) {
|
||||
const status = initWorker();
|
||||
return NextResponse.json({ status, time: new Date().toISOString() });
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
return NextResponse.json({ status: initWorker() });
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getArticleFull, getRelatedArticles } from '@/lib/queries';
|
||||
import SourceAttribution from '@/components/SourceAttribution';
|
||||
import RelatedArticles from '@/components/RelatedArticles';
|
||||
import { InArticleAd, SidebarAd } from '@/components/ads/placements';
|
||||
import { env } from '@/lib/env';
|
||||
import { formatFull } from '@/lib/format';
|
||||
import { safeParse } from '@/lib/llm/parse';
|
||||
import { SECTIONS } from '@/data/feeds';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ArticleData {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
headline: string | null;
|
||||
body: string | null;
|
||||
takeaways: string | null;
|
||||
tags: string | null;
|
||||
siteName: string;
|
||||
category: string;
|
||||
author: string | null;
|
||||
publishedAt: Date | null;
|
||||
synthesizedAt: Date | null;
|
||||
sourceUrl: string;
|
||||
canonicalUrl: string;
|
||||
image: string | null;
|
||||
sources: string | null;
|
||||
originalText: string | null;
|
||||
llmProvider: string | null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata> {
|
||||
const a = await getArticleFull(params.slug);
|
||||
if (!a) return { title: 'Briefing not found' };
|
||||
const title = a.headline ?? a.title;
|
||||
const description = a.body?.split('\n\n')[0]?.slice(0, 160) ?? '';
|
||||
const sectionLabel = SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing';
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: a.canonicalUrl },
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title,
|
||||
description,
|
||||
url: a.canonicalUrl,
|
||||
siteName: env.siteName,
|
||||
publishedTime: a.publishedAt?.toISOString(),
|
||||
modifiedTime: a.synthesizedAt?.toISOString(),
|
||||
section: sectionLabel,
|
||||
tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined,
|
||||
images: a.image
|
||||
? [{ url: a.image, width: 1200, height: 630, alt: title }]
|
||||
: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }],
|
||||
authors: [a.siteName],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: a.image ? [a.image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const a = await getArticleFull(params.slug);
|
||||
if (!a || !a.body) notFound();
|
||||
|
||||
const paragraphs = a.body.split('\n\n').map((p) => p.trim()).filter(Boolean);
|
||||
const takeaways = a.takeaways ? safeParse<string[]>(a.takeaways) : [];
|
||||
const tags = a.tags ? safeParse<string[]>(a.tags) : [];
|
||||
const relatedItems = await getRelatedArticles(a.slug, 6);
|
||||
const title = a.headline ?? a.title;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||
<div className="grid gap-10 lg:grid-cols-3">
|
||||
{/* Article column */}
|
||||
<article className="lg:col-span-2">
|
||||
<header>
|
||||
<p className="mb-3 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="rounded bg-maple-600/10 px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-maple-700">
|
||||
{SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing'}
|
||||
</span>
|
||||
<span className="text-ink-500">via {a.siteName}</span>
|
||||
{a.publishedAt && (
|
||||
<span className="text-ink-400">· {formatFull(a.publishedAt.toISOString())}</span>
|
||||
)}
|
||||
</p>
|
||||
<h1 className="font-display text-3xl font-bold leading-tight text-ink-950 sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-ink-500">
|
||||
Original headline:{' '}
|
||||
<span className="text-ink-700">“{a.title}”</span> — {a.siteName}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{a.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.image}
|
||||
alt={title}
|
||||
className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{takeaways.length > 0 && (
|
||||
<section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-maple-800">
|
||||
Key takeaways
|
||||
</h2>
|
||||
<ul className="mt-3 grid gap-2 sm:grid-cols-1">
|
||||
{takeaways.map((t, i) => (
|
||||
<li key={i} className="flex gap-2 text-[15px] leading-snug text-ink-800">
|
||||
<span aria-hidden className="mt-0.5 font-bold text-maple-600">•</span>
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="article-prose mt-8">
|
||||
{paragraphs.slice(0, 1).map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
|
||||
{/* In-article ad: between rewritten paragraphs 1 and 2 */}
|
||||
<InArticleAd />
|
||||
|
||||
{paragraphs.slice(1).map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SourceAttribution
|
||||
siteName={a.siteName}
|
||||
sourceUrl={a.sourceUrl}
|
||||
sourcesJson={a.sources}
|
||||
author={a.author}
|
||||
publishedAt={a.publishedAt?.toISOString()}
|
||||
/>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className="rounded-full bg-ink-100 px-3 py-1 text-sm text-ink-700 hover:bg-maple-100 hover:text-maple-800"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-10 border-t border-ink-200 pt-4 text-xs text-ink-400">
|
||||
Rewrite provenance: {a.llmProvider ?? 'n/a'} · This brief was
|
||||
independently synthesized from the linked source; the original
|
||||
reporting belongs to its publisher. Read the original:{' '}
|
||||
<a
|
||||
href={a.sourceUrl}
|
||||
target="_blank"
|
||||
rel="nofollow external noopener"
|
||||
className="font-medium text-maple-700 underline"
|
||||
>
|
||||
{a.siteName}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<RelatedArticles items={relatedItems} />
|
||||
</article>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside aria-label="Sidebar" className="space-y-8">
|
||||
<SidebarAd tall />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import ContactForm from '@/components/ContactForm';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact',
|
||||
description: 'Contact MapleBrief: corrections, legal DMCA inquiries, and advertising.',
|
||||
alternates: { canonical: '/contact' },
|
||||
};
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
|
||||
<h1 className="font-display text-4xl font-bold text-ink-950">Contact</h1>
|
||||
<p className="mt-3 text-[15px] text-ink-600">
|
||||
Editorial corrections, DMCA / copyright notices, and AdSense partner
|
||||
inquiries are all welcome. Legal requests should include the URL of the
|
||||
affected brief and the original source URL.
|
||||
</p>
|
||||
|
||||
<ContactForm />
|
||||
|
||||
<p className="mt-8 text-xs text-ink-400">
|
||||
Prefer email? See the <Link href="/about" className="underline">about page</Link>{' '}
|
||||
for the masthead, and this form returns the same destination.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--ink: #161614;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ink-50 text-ink-900 antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-maple-200 text-ink-950;
|
||||
}
|
||||
|
||||
/* Prose-like base for article bodies */
|
||||
.article-prose p {
|
||||
@apply mb-5 text-[1.0625rem] leading-[1.75] text-ink-800;
|
||||
}
|
||||
|
||||
.article-prose p:first-of-type::first-letter {
|
||||
@apply float-left mr-2 font-display text-5xl leading-[0.9] font-bold text-maple-600;
|
||||
}
|
||||
|
||||
/* Screenshot-like shimmer for ad placeholders before AdSense loads */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -400px 0; }
|
||||
100% { background-position: 400px 0; }
|
||||
}
|
||||
.ad-shimmer {
|
||||
background: linear-gradient(90deg, #e6e6e3 25%, #f4f4f3 50%, #e6e6e3 75%);
|
||||
background-size: 800px 100%;
|
||||
animation: shimmer 1.6s infinite linear;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import Header, { SectionPills } from '@/components/layout/Header';
|
||||
import { HeaderAd } from '@/components/ads/placements';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(env.siteUrl),
|
||||
title: {
|
||||
default: `${env.siteName} — Synthesized Canadian news briefings`,
|
||||
template: `%s · ${env.siteName}`,
|
||||
},
|
||||
description: env.siteDescription,
|
||||
alternates: { canonical: '/' },
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon-64.png', sizes: '64x64', type: 'image/png' },
|
||||
],
|
||||
apple: '/icon-192.png',
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: env.siteName,
|
||||
title: `${env.siteName} — Synthesized Canadian news briefings`,
|
||||
description: env.siteDescription,
|
||||
url: env.siteUrl,
|
||||
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${env.siteName} — Synthesized Canadian news briefings`,
|
||||
description: env.siteDescription,
|
||||
images: ['/og-image.png'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en-CA">
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-focus-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded focus:bg-maple-600 focus:px-4 focus:py-2 focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Header />
|
||||
<SectionPills />
|
||||
<HeaderAd />
|
||||
<main id="main" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<p className="font-display text-6xl font-bold text-ink-300">404</p>
|
||||
<h1 className="mt-4 font-display text-2xl font-bold text-ink-900">
|
||||
That briefing has left the map
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-ink-500">
|
||||
The page you were looking for doesn’t exist or may have been superseded
|
||||
by a newer synthesis.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 rounded-lg bg-maple-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-maple-700"
|
||||
>
|
||||
Back to today’s briefs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getPublishedArticles, getTagList } from '@/lib/queries';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import { InFeedAd, SidebarAd } from '@/components/ads/placements';
|
||||
import Link from 'next/link';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { page?: string };
|
||||
}) {
|
||||
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1);
|
||||
const PER = 21;
|
||||
const [articles, tags] = await Promise.all([
|
||||
getPublishedArticles('top-stories', PER, (page - 1) * PER),
|
||||
getTagList(12),
|
||||
]);
|
||||
|
||||
const hero = articles[0];
|
||||
const rest = articles.slice(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Main column */}
|
||||
<div className="lg:col-span-2">
|
||||
{hero ? (
|
||||
<Link
|
||||
href={`/article/${hero.slug}`}
|
||||
className="group mb-8 block overflow-hidden rounded-2xl border border-ink-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="relative aspect-[16/8] overflow-hidden bg-ink-100">
|
||||
{hero.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={hero.image}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-masthead-gradient">
|
||||
<span className="font-display text-2xl font-bold text-white/90">
|
||||
{env.siteName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink-950/95 via-ink-950/70 to-transparent p-5 pt-16">
|
||||
<span className="mb-2 inline-block rounded bg-maple-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider text-white">
|
||||
Lead briefing
|
||||
</span>
|
||||
<h1 className="font-display text-2xl font-bold leading-tight text-white sm:text-3xl">
|
||||
{hero.headline ?? hero.title}
|
||||
</h1>
|
||||
<p className="mt-2 line-clamp-2 text-sm text-ink-200">{hero.excerpt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{interleaveInFeedAds(rest, 3).map((node) => node)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="space-y-8" aria-label="Sidebar">
|
||||
<SidebarAd tall />
|
||||
{tags.length > 0 && (
|
||||
<section className="rounded-xl border border-ink-200 bg-white p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
Trending topics
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className="rounded-full bg-ink-100 px-3 py-1 text-sm text-ink-700 hover:bg-maple-100 hover:text-maple-800"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Interleave an InFeedAd after every `every`th card. */
|
||||
function interleaveInFeedAds(
|
||||
cards: Awaited<ReturnType<typeof getPublishedArticles>>,
|
||||
every: number,
|
||||
): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
cards.forEach((card, i) => {
|
||||
out.push(<ArticleCard key={card.id} card={card} />);
|
||||
if ((i + 1) % every === 0) out.push(<InFeedAd key={`ad-${i + 1}`} index={i + 1} />);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-ink-300 bg-white p-12 text-center">
|
||||
<p className="font-display text-xl font-bold text-ink-800">
|
||||
No briefings yet
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-ink-500">
|
||||
The ingestion worker has not published anything yet. Trigger your first
|
||||
run (see the README “First run” section) and this page will fill with
|
||||
synthesized briefings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Privacy Policy',
|
||||
description: 'How MapleBrief collects, uses, and discloses personal information, including Google AdSense and DART cookies.',
|
||||
alternates: { canonical: '/privacy-policy' },
|
||||
};
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="font-display text-xl font-bold text-ink-900">{title}</h2>
|
||||
<div className="mt-3 space-y-3 text-[15px] leading-relaxed text-ink-700">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||
<h1 className="font-display text-4xl font-bold text-ink-950">Privacy Policy</h1>
|
||||
<p className="mt-3 text-sm text-ink-500">
|
||||
Last updated: {new Date().toISOString().slice(0, 10)} · {env.siteName} ({env.siteUrl})
|
||||
</p>
|
||||
|
||||
<Section title="1. Service overview">
|
||||
<p>
|
||||
{env.siteName} (“MapleBrief”, “we”, “us”) is an
|
||||
automated news-digest site. We aggregate publicly available news feeds
|
||||
from Canadian news publishers, independently review and summarize that
|
||||
reporting into original briefs using a language model, and present them
|
||||
with clear attribution to each original publisher.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="2. Information we collect">
|
||||
<p>
|
||||
We do not require accounts and do not ask you to sign in. We do not
|
||||
collect names, email addresses, or other identifying personal data
|
||||
beyond what is automatically generated by your browser.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Server access logs (IP address, user agent, referrer, timestamp).</li>
|
||||
<li>De-identified interaction metrics collected by third-party analytics and advertising vendors.</li>
|
||||
<li>Data collected by our advertising partner, Google, as described below.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="3. Google AdSense and third-party advertising">
|
||||
<p>
|
||||
We display advertising served by Google AdSense. Google and its
|
||||
partners use cookies — including the Google DART (DoubleClick
|
||||
Ad Tracking) cookie — to serve ads based on your prior visits to this
|
||||
website and other sites on the Internet.
|
||||
</p>
|
||||
<p>
|
||||
DART enables Google and its partners to serve ads to visitors based on
|
||||
their visit to this site and other sites on the Internet.
|
||||
</p>
|
||||
<p>
|
||||
Visitors may opt out of the use of the DART cookie by visiting the
|
||||
Google Ad Settings page:
|
||||
<a
|
||||
href="https://www.google.com/settings/ads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-maple-700 underline"
|
||||
>
|
||||
https://www.google.com/settings/ads
|
||||
</a>
|
||||
. You can also opt out of third-party advertising cookies generally at
|
||||
<a
|
||||
href="https://www.aboutads.info/choices"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-maple-700 underline"
|
||||
>
|
||||
https://www.aboutads.info/choices
|
||||
</a>{' '}
|
||||
(US/EU) or
|
||||
<a
|
||||
href="https://youradchoices.ca"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-maple-700 underline"
|
||||
>
|
||||
https://youradchoices.ca
|
||||
</a>{' '}
|
||||
(Canada). Your choice of opt-out cookies applies per browser and must
|
||||
be repeated for each browser and device you use.
|
||||
</p>
|
||||
<p>
|
||||
Google's privacy policy regarding the use of advertising cookies
|
||||
(including the DART cookie) is available at:
|
||||
<a
|
||||
href="https://policies.google.com/technologies/ads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-maple-700 underline"
|
||||
>
|
||||
https://policies.google.com/technologies/ads
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="4. Cookies">
|
||||
<p>
|
||||
We may use first-party cookies for basic site functionality (e.g.,
|
||||
remembering preferences where applicable). Third-party cookies are
|
||||
set by our advertising vendors as described in Section 3. You can
|
||||
control cookies through your browser settings; blocking all cookies
|
||||
may affect parts of the site.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="5. Links to other sites">
|
||||
<p>
|
||||
Our briefs link out to original publisher articles. Those publishers
|
||||
have their own privacy policies, and we are not responsible for their
|
||||
content or practices.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="6. Children's privacy">
|
||||
<p>
|
||||
Our service is not directed to children, and we do not knowingly
|
||||
collect personal information from anyone under 13 (or the relevant
|
||||
local age in Canada).
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="7. Your rights (PIPEDA and provincial privacy law)">
|
||||
<p>
|
||||
To the extent PIPEDA or provincial privacy laws apply, you may request
|
||||
access to, correction of, or deletion of personal information we hold
|
||||
about you. Contact us using the details below.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="8. Changes to this policy">
|
||||
<p>
|
||||
We may update this policy from time to time. The most recent version
|
||||
will always be published at{' '}
|
||||
<Link href="/privacy-policy" className="font-medium text-maple-700 underline">
|
||||
/privacy-policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="9. Contact">
|
||||
<p>
|
||||
Questions about this policy: see our{' '}
|
||||
<Link href="/contact" className="font-medium text-maple-700 underline">
|
||||
contact page
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const base = env.siteUrl.replace(/\/$/, '');
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
// Allow everything including Googlebot & Google-InspectionTool.
|
||||
// Only block the admin area from indexing/crawling.
|
||||
userAgent: ['*'],
|
||||
allow: '/',
|
||||
disallow: ['/admin', '/api/'],
|
||||
},
|
||||
{
|
||||
userAgent: 'Googlebot',
|
||||
allow: ['/', '/article/*/'],
|
||||
disallow: ['/admin', '/api/'],
|
||||
},
|
||||
{
|
||||
// AdSense injection backend (unofficial; harmless)
|
||||
userAgent: 'Google-AdSense',
|
||||
allow: '/',
|
||||
},
|
||||
],
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getPublishedArticles } from '@/lib/queries';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STATIC_PATHS = ['', '/about', '/contact', '/privacy-policy', '/terms-of-service'];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const now = new Date();
|
||||
const base = env.siteUrl.replace(/\/$/, '');
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = STATIC_PATHS.map((p) => ({
|
||||
url: `${base}${p}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: p === '' ? 1.0 : 0.4,
|
||||
}));
|
||||
|
||||
// All synthesized briefs, newest first, up to 5k entries (sitemap cap guidance).
|
||||
const articles = await getPublishedArticles('all', 5000);
|
||||
const articleEntries: MetadataRoute.Sitemap = articles.map((a) => ({
|
||||
url: `${base}/article/${a.slug}`,
|
||||
lastModified: a.publishedAt ? new Date(a.publishedAt) : now,
|
||||
changeFrequency: 'hourly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...articleEntries];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getArticlesByTag, getTagList } from '@/lib/queries';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { tag: string };
|
||||
}): Promise<Metadata> {
|
||||
return { title: `#${params.tag}`, alternates: { canonical: `/tag/${params.tag}` } };
|
||||
}
|
||||
|
||||
export default async function TagPage({ params }: { params: { tag: string } }) {
|
||||
const tag = decodeURIComponent(params.tag);
|
||||
const [articles, allTags] = await Promise.all([
|
||||
getArticlesByTag(tag, 30),
|
||||
getTagList(16),
|
||||
]);
|
||||
if (articles.length === 0 && !allTags.includes(tag.trim().toLowerCase())) {
|
||||
// still render if tag existed in DB but had zero recent articles
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||
<h1 className="mb-6 font-display text-3xl font-bold text-ink-900">Topic: #{tag}</h1>
|
||||
{articles.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-300 bg-white p-10 text-center text-sm text-ink-500">
|
||||
No briefings under this tag yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{articles.map((card) => (
|
||||
<ArticleCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allTags.length > 0 && (
|
||||
<div className="mt-10 flex flex-wrap gap-2">
|
||||
{allTags.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className="rounded-full bg-ink-100 px-3 py-1 text-sm text-ink-700 hover:bg-maple-100 hover:text-maple-800"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Terms of Service',
|
||||
description: 'Terms governing use of MapleBrief and its synthesized news briefs.',
|
||||
alternates: { canonical: '/terms-of-service' },
|
||||
};
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="font-display text-xl font-bold text-ink-900">{title}</h2>
|
||||
<div className="mt-3 space-y-3 text-[15px] leading-relaxed text-ink-700">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||
<h1 className="font-display text-4xl font-bold text-ink-950">Terms of Service</h1>
|
||||
<p className="mt-3 text-sm text-ink-500">
|
||||
Last updated: {new Date().toISOString().slice(0, 10)}
|
||||
</p>
|
||||
|
||||
<Section title="1. Acceptance of terms">
|
||||
<p>
|
||||
By accessing or using MapleBrief (“the Site”), you agree to be
|
||||
bound by these Terms of Service. If you do not agree, discontinue use.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="2. Nature of the service">
|
||||
<p>
|
||||
MapleBrief provides automated, AI-assisted summaries of publicly
|
||||
available news from third-party Canadian publishers. Briefs are
|
||||
synthesized for information convenience; each brief links to, and
|
||||
attributes, the original reporting. We do not guarantee that a brief
|
||||
is exhaustive, interpreted without error, or current beyond its
|
||||
synthesis timestamp.
|
||||
</p>
|
||||
<p>
|
||||
Nothing on the Site constitutes professional, legal, financial,
|
||||
medical, or investment advice.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="3. Intellectual property and attribution">
|
||||
<p>
|
||||
The original articles remain the exclusive property of their
|
||||
respective publishers. MapleBrief's briefs are independently
|
||||
authored; links to original sources are provided with{' '}
|
||||
<code className="rounded bg-ink-100 px-1 text-sm">rel="nofollow"</code>{' '}
|
||||
and are intended as outbound referrals, not endorsements.
|
||||
</p>
|
||||
<p>
|
||||
You may share brief links for personal, non-commercial reference. You
|
||||
may not scrape, mirror, resell, or systematically redistribute the
|
||||
Site's content.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="4. Acceptable use">
|
||||
<p>
|
||||
You agree not to: (a) interfere with or disrupt the Site or its
|
||||
infrastructure; (b) attempt to bypass rate limits or access controls;
|
||||
(c) use the Site for unlawful purposes; (d) attempt to extract the
|
||||
underlying raw feed content in bulk.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="5. Termination">
|
||||
<p>
|
||||
We may modify or terminate any part of the Site at any time, with or
|
||||
without notice.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="6. Limitation of liability">
|
||||
<p>
|
||||
To the maximum extent permitted by law, MapleBrief shall not be
|
||||
liable for indirect, incidental, special, consequential, or punitive
|
||||
damages arising from your use of (or inability to use) the Site.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="7. Governing law">
|
||||
<p>
|
||||
These terms are governed by the laws of Ontario, Canada, without
|
||||
regard to conflict-of-law principles, and the exclusive remedy venue
|
||||
is the courts of Ontario.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="8. Contact">
|
||||
<p>
|
||||
For questions about these terms, see our{' '}
|
||||
<Link href="/contact" className="font-medium text-maple-700 underline">
|
||||
contact page
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Link from 'next/link';
|
||||
import type { ArticleCard as Card } from '@/lib/queries';
|
||||
import { formatRelativeTime } from '@/lib/format';
|
||||
|
||||
export default function ArticleCard({ card }: { card: Card }) {
|
||||
const href = `/article/${card.slug}`;
|
||||
const title = card.headline ?? card.title;
|
||||
return (
|
||||
<article className="group flex flex-col overflow-hidden rounded-xl border border-ink-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
|
||||
<Link
|
||||
href={href}
|
||||
className="relative block aspect-[16/9] overflow-hidden bg-ink-100"
|
||||
>
|
||||
{card.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={card.image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center font-display text-4xl text-ink-300">
|
||||
{card.siteName.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute left-3 top-3 rounded bg-ink-950/80 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">
|
||||
{card.siteName}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<h3 className="font-display text-lg font-bold leading-snug text-ink-900 group-hover:text-maple-700">
|
||||
<Link href={href}>{title}</Link>
|
||||
</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-ink-600">
|
||||
{card.excerpt}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-ink-500">
|
||||
<span>{card.publishedAt ? formatRelativeTime(card.publishedAt) : 'recent'}</span>
|
||||
<span className="flex gap-1">
|
||||
{card.tags.slice(0, 2).map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className="rounded bg-ink-100 px-1.5 py-0.5 hover:bg-maple-100 hover:text-maple-800"
|
||||
>
|
||||
{t}
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ContactForm() {
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
setState('sending');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: data.get('email'),
|
||||
topic: data.get('topic'),
|
||||
url: data.get('url'),
|
||||
message: data.get('message'),
|
||||
}),
|
||||
});
|
||||
setState(res.ok ? 'sent' : 'error');
|
||||
} catch {
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'sent') {
|
||||
return (
|
||||
<div className="mt-8 rounded-xl border border-emerald-300 bg-emerald-50 p-6 text-[15px] text-emerald-900">
|
||||
Thanks — your message has been queued. For time-sensitive legal
|
||||
notices, also email the address listed on the site.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium text-ink-800">Your email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-sm focus:border-maple-600 focus:outline-none"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium text-ink-800">Topic</span>
|
||||
<select name="topic" className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-sm" defaultValue="correction">
|
||||
<option value="correction">Correction</option>
|
||||
<option value="dmca">Copyright / DMCA notice</option>
|
||||
<option value="advertising">AdSense / advertising</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium text-ink-800">Affected brief URL (if any)</span>
|
||||
<input
|
||||
name="url"
|
||||
type="url"
|
||||
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-sm focus:border-maple-600 focus:outline-none"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium text-ink-800">Message</span>
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-sm focus:border-maple-600 focus:outline-none"
|
||||
placeholder="…"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'sending'}
|
||||
className="rounded-lg bg-maple-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
|
||||
>
|
||||
{state === 'sending' ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
{state === 'error' && (
|
||||
<p className="text-sm text-red-700">Could not send — please try again.</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from 'next/link';
|
||||
import type { ArticleCard } from '@/lib/queries';
|
||||
import { formatRelativeTime } from '@/lib/format';
|
||||
|
||||
export default function RelatedArticles({ items }: { items: ArticleCard[] }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="mt-12 border-t border-ink-200 pt-8" aria-label="Related briefings">
|
||||
<h2 className="font-display text-xl font-bold text-ink-900">Related briefings</h2>
|
||||
<ul className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{items.map((a) => (
|
||||
<li key={a.id} className="flex gap-3">
|
||||
<div className="h-16 w-24 flex-shrink-0 overflow-hidden rounded-md bg-ink-100">
|
||||
{a.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={a.image} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/article/${a.slug}`}
|
||||
className="block font-display text-[15px] font-semibold leading-snug text-ink-800 hover:text-maple-700"
|
||||
>
|
||||
{a.headline ?? a.title}
|
||||
</Link>
|
||||
<span className="mt-1 block text-xs text-ink-500">
|
||||
{a.siteName}
|
||||
{a.publishedAt ? ` · ${formatRelativeTime(a.publishedAt)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatRelativeTime, formatDate } from '@/lib/format';
|
||||
import { safeParse } from '@/lib/llm/parse';
|
||||
|
||||
interface Sources {
|
||||
siteName: string;
|
||||
sourceUrl: string;
|
||||
sourcesJson: string | null;
|
||||
author?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution block — required for AdSense content policy.
|
||||
* Every outgoing link to the original story is `rel="nofollow external"`.
|
||||
*/
|
||||
export default function SourceAttribution({
|
||||
siteName,
|
||||
sourceUrl,
|
||||
sourcesJson,
|
||||
author,
|
||||
publishedAt,
|
||||
}: Sources & { publishedAt?: string | null }) {
|
||||
const sources = sourcesJson ? safeParse<string[]>(sourcesJson) : [siteName];
|
||||
return (
|
||||
<aside
|
||||
className="my-8 rounded-xl border border-ink-200 bg-ink-50 p-5 text-sm"
|
||||
aria-label="Source attribution"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
Sources analyzed
|
||||
</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-ink-700">
|
||||
{sources.map((s, i) => (
|
||||
<li key={`${s}-${i}`}>
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="nofollow external noopener"
|
||||
className="font-medium text-maple-700 underline decoration-maple-300 underline-offset-2 hover:text-maple-800"
|
||||
>
|
||||
{s}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-500">
|
||||
{author ? `${author} · ` : ''}
|
||||
{publishedAt
|
||||
? `Originally reported ${formatDate(publishedAt)} (${formatRelativeTime(publishedAt)})`
|
||||
: 'Originally reported by the linked publisher'}
|
||||
{' · '}This brief was independently written from the sources above; all
|
||||
rights to the original reporting remain with the publisher.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type Status = 'unknown' | 'authed' | 'denied';
|
||||
|
||||
/**
|
||||
* Client-side admin gate.
|
||||
*
|
||||
* 1. If `x-admin-key` is stored in sessionStorage → validate via
|
||||
* /api/admin/verify.
|
||||
* 2. Otherwise show a key entry form (basic-auth fallback for users who
|
||||
* prefer the `ADMIN_USER:ADMIN_KEY` header).
|
||||
*
|
||||
* The key is never written to localStorage and is cleared on a hard tab
|
||||
* close. For hardening, front the admin area with real HTTP Basic auth
|
||||
* at the reverse-proxy layer too.
|
||||
*/
|
||||
export default function AdminGate({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<Status>('unknown');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [keyInput, setKeyInput] = useState('');
|
||||
|
||||
const validate = useCallback(
|
||||
async (key: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/verify', {
|
||||
headers: { 'x-admin-key': key },
|
||||
});
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('mb_admin_key', key);
|
||||
setStatus('authed');
|
||||
} else {
|
||||
setStatus('denied');
|
||||
setError('Invalid admin key.');
|
||||
}
|
||||
} catch {
|
||||
setError('Network error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem('mb_admin_key');
|
||||
if (stored) validate(stored);
|
||||
else setStatus('denied');
|
||||
}, [validate]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (keyInput.trim()) await validate(keyInput.trim());
|
||||
}
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('mb_admin_key');
|
||||
setStatus('denied');
|
||||
setKeyInput('');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (status === 'authed') {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-900">
|
||||
Admin · Settings
|
||||
</h1>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-md border border-ink-300 px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-24">
|
||||
<div className="rounded-2xl border border-ink-200 bg-white p-8 shadow-sm">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-900">Admin</h1>
|
||||
<p className="mt-1 text-sm text-ink-500">
|
||||
Enter the admin key from <code>ADMIN_API_KEY</code> (or your{' '}
|
||||
<code>USER:ADMIN_API_KEY</code> basic-auth string) to continue.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-6 space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder="Admin key"
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-ink-300 px-3 py-2.5 text-sm focus:border-maple-600 focus:outline-none"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-700">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-lg bg-maple-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Verifying…' : 'Unlock'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-xs text-ink-400">
|
||||
Tip: keep the key in a password manager; it is only held in your
|
||||
browser session while you have this tab open.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface Settings {
|
||||
provider: 'openai' | 'anthropic' | 'ollama';
|
||||
apiKeys: { openai: string; anthropic: string };
|
||||
ollamaBase: string;
|
||||
models: { openai: string; anthropic: string; ollama: string };
|
||||
synthesisPrompt: string;
|
||||
}
|
||||
|
||||
interface KeySource {
|
||||
openai: boolean;
|
||||
anthropic: boolean;
|
||||
ollama: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsForm() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [keyFromDb, setKeyFromDb] = useState<KeySource | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const adminHeaders = useCallback(
|
||||
() => ({
|
||||
'x-admin-key': sessionStorage.getItem('mb_admin_key') ?? '',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
headers: { 'x-admin-key': sessionStorage.getItem('mb_admin_key') ?? '' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'load failed');
|
||||
setSettings(data.settings as Settings);
|
||||
setKeyFromDb(data.keySources as KeySource);
|
||||
} catch (e) {
|
||||
setLoadError(e instanceof Error ? e.message : 'load failed');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (loadError) {
|
||||
return <p className="text-sm text-red-700">Could not load settings: {loadError}</p>;
|
||||
}
|
||||
if (!settings || !keyFromDb) {
|
||||
return <p className="text-sm text-ink-500">Loading…</p>;
|
||||
}
|
||||
|
||||
const prompt: Settings = {
|
||||
provider: settings.provider,
|
||||
apiKeys: { ...settings.apiKeys },
|
||||
ollamaBase: settings.ollamaBase ?? 'http://localhost:11434',
|
||||
models: { ...settings.models },
|
||||
synthesisPrompt: settings.synthesisPrompt,
|
||||
};
|
||||
|
||||
function patch<K extends keyof Settings>(key: K, value: Settings[K]) {
|
||||
setSettings((s) => (s ? { ...s, [key]: value } : s));
|
||||
setSaveMsg(null);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: adminHeaders(),
|
||||
body: JSON.stringify(prompt),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaveMsg({ ok: res.ok, text: res.ok ? 'Settings saved.' : (data.error ?? 'save failed') });
|
||||
if (res.ok) {
|
||||
setKeyFromDb(data.keySources as KeySource);
|
||||
}
|
||||
} catch {
|
||||
setSaveMsg({ ok: false, text: 'Network error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/settings/test', {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestMsg(
|
||||
data.ok
|
||||
? { ok: true, text: data.message ?? 'Connection OK.' }
|
||||
: { ok: false, text: data.error ?? 'Test failed' },
|
||||
);
|
||||
} catch {
|
||||
setTestMsg({ ok: false, text: 'Network error' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={save} className="space-y-8">
|
||||
{/* Provider selection */}
|
||||
<section className="rounded-xl border border-ink-200 bg-white p-6">
|
||||
<h2 className="font-semibold text-ink-900">Active provider</h2>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{(['openai', 'anthropic', 'ollama'] as const).map((p) => (
|
||||
<label
|
||||
key={p}
|
||||
className={`cursor-pointer rounded-lg border p-4 text-center transition ${
|
||||
prompt.provider === p
|
||||
? 'border-maple-600 bg-maple-50 ring-1 ring-maple-600'
|
||||
: 'border-ink-200 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={p}
|
||||
checked={prompt.provider === p}
|
||||
onChange={() => patch('provider', p)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="block text-sm font-semibold capitalize text-ink-900">{p}</span>
|
||||
<span className="mt-1 block text-xs text-ink-500">
|
||||
{p === 'openai' && 'GPT-4o / 4o-mini · cloud'}
|
||||
{p === 'anthropic' && 'Claude 3.5 Sonnet / Haiku · cloud'}
|
||||
{p === 'ollama' && 'Local · zero cost'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<label className="mt-5 block text-sm">
|
||||
<span className="font-medium text-ink-800">Model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={prompt.models[prompt.provider]}
|
||||
onChange={(e) =>
|
||||
patch('models', { ...prompt.models, [prompt.provider]: e.target.value })
|
||||
}
|
||||
list="model-suggestions"
|
||||
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{prompt.provider === 'openai' && (
|
||||
<>
|
||||
<option value="gpt-4o-mini" />
|
||||
<option value="gpt-4o" />
|
||||
</>
|
||||
)}
|
||||
{prompt.provider === 'anthropic' && (
|
||||
<>
|
||||
<option value="claude-3-5-sonnet-latest" />
|
||||
<option value="claude-3-5-haiku-latest" />
|
||||
</>
|
||||
)}
|
||||
{prompt.provider === 'ollama' && (
|
||||
<>
|
||||
<option value="llama3.1:8b" />
|
||||
<option value="mistral:latest" />
|
||||
<option value="qwen2.5:14b" />
|
||||
</>
|
||||
)}
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
{/* API key — only cloud providers */}
|
||||
{prompt.provider !== 'ollama' ? (
|
||||
<label className="mt-5 block text-sm">
|
||||
<span className="font-medium text-ink-800">
|
||||
{prompt.provider === 'openai' ? 'OpenAI' : 'Anthropic'} API key
|
||||
<span className="ml-2 rounded bg-ink-100 px-1.5 py-0.5 text-[11px] font-normal text-ink-500">
|
||||
{keyFromDb[prompt.provider] ? 'stored in database' : 'coming from env vars'}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={prompt.apiKeys[prompt.provider]}
|
||||
onChange={(e) =>
|
||||
patch('apiKeys', { ...prompt.apiKeys, [prompt.provider]: e.target.value })
|
||||
}
|
||||
placeholder={keyFromDb[prompt.provider] ? 'Enter to update (current is saved)' : 'sk-…'}
|
||||
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
|
||||
/>
|
||||
{!keyFromDb[prompt.provider] && (
|
||||
<span className="mt-1 block text-xs text-ink-400">
|
||||
Blank means “use the env var” (OPENAI_API_KEY /
|
||||
ANTHROPIC_API_KEY). Saving a value stores it encrypted-at-rest in
|
||||
SQLite (phone-in-BE BYO; see README on secret handling).
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
) : (
|
||||
<label className="mt-5 block text-sm">
|
||||
<span className="font-medium text-ink-800">Ollama base URL</span>
|
||||
<input
|
||||
type="url"
|
||||
value={prompt.ollamaBase}
|
||||
onChange={(e) => patch('ollamaBase', e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-ink-400">
|
||||
For “localhost” you must also set OLLAMA_CORS=true on the
|
||||
Ollama host (see README).
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Synthesis prompt */}
|
||||
<section className="rounded-xl border border-ink-200 bg-white p-6">
|
||||
<h2 className="font-semibold text-ink-900">Synthesis system prompt</h2>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
This is the standing instruction the LLM receives on every
|
||||
synthesis. Edit to change tone, length, or structure — keep the
|
||||
“strict JSON only” contract so parsing stays reliable.
|
||||
</p>
|
||||
<textarea
|
||||
rows={12}
|
||||
value={prompt.synthesisPrompt}
|
||||
onChange={(e) => patch('synthesisPrompt', e.target.value)}
|
||||
className="mt-3 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-[13px] leading-relaxed focus:border-maple-600 focus:outline-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<section className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-maple-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save settings'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={test}
|
||||
disabled={testing || saving}
|
||||
className="rounded-lg border border-ink-300 px-5 py-2.5 text-sm font-semibold text-ink-700 hover:bg-ink-100 disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
{saveMsg && (
|
||||
<span
|
||||
className={`text-sm ${saveMsg.ok ? 'text-emerald-700' : 'text-red-700'}`}
|
||||
>
|
||||
{saveMsg.text}
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{testMsg && (
|
||||
<p
|
||||
className={`rounded-lg border p-4 text-sm ${
|
||||
testMsg.ok
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-900'
|
||||
: 'border-red-300 bg-red-50 text-red-900'
|
||||
}`}
|
||||
>
|
||||
{testMsg.text}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
adsbygoogle?: unknown[];
|
||||
_adsbygoogle?: unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
interface AdUnitProps {
|
||||
/** AdSense ad-unit "slot" id (numeric, from your AdSense UI). */
|
||||
slotId: string;
|
||||
/** Client/publisher id, e.g. ca-pub-XXXX. Defaults to the env value. */
|
||||
clientId?: string;
|
||||
/** Sizing format: 'auto' (responsive) or explicit data-ad-format. */
|
||||
format?: 'auto' | 'horizontal' | 'vertical';
|
||||
/** Slot key used for the adsense client default. */
|
||||
label?: string;
|
||||
/** Extra <ins> attributes, e.g. data-ad-slot overrides. */
|
||||
className?: string;
|
||||
/** Render even when no client id is configured (shows watermark). */
|
||||
alwaysRender?: boolean;
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
|
||||
/** Inject exactly one adsbygoogle.js per page (guarded singleton). */
|
||||
function ensureAdsenseScript(clientId: string): Promise<void> {
|
||||
if (scriptPromise) return scriptPromise;
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(
|
||||
clientId,
|
||||
)}`;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () =>
|
||||
reject(new Error('AdSense script failed to load'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central safe AdUnit component.
|
||||
*
|
||||
* - Injects adsbygoogle.js once per page.
|
||||
* - Defers request to `(window.adsbygoogle).push({})` on mount.
|
||||
* - If no client id is configured (local dev) it renders a labelled
|
||||
* shimmer placeholder so the layout is testable pre-approval.
|
||||
* - `format: 'auto'` produces responsive drops; 'horizontal' is the
|
||||
* 728x90 leaderboard shape; 'vertical' is used for sidebar 300x250/600.
|
||||
*/
|
||||
export default function AdUnit({
|
||||
slotId,
|
||||
clientId,
|
||||
format = 'auto',
|
||||
label = 'Advertisement',
|
||||
className = '',
|
||||
}: AdUnitProps) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const pushed = useRef(false);
|
||||
const envClientId =
|
||||
typeof process !== 'undefined'
|
||||
? (process.env.NEXT_PUBLIC_ADSENSE_CLIENT_ID ?? '')
|
||||
: '';
|
||||
const client = clientId ?? envClientId;
|
||||
const configured = Boolean(client && slotId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configured || pushed.current) return;
|
||||
let cancelled = false;
|
||||
ensureAdsenseScript(client)
|
||||
.then(() => {
|
||||
if (cancelled || pushed.current) return;
|
||||
pushed.current = true;
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep placeholder */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [configured, client, slotId]);
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
// Placeholder so AdSense-free local previews keep the intended layout.
|
||||
<div
|
||||
aria-hidden
|
||||
className={`ad-shimmer relative w-full ${className}`}
|
||||
style={{
|
||||
minHeight: format === 'vertical' ? 250 : 90,
|
||||
minWidth: format === 'vertical' ? 180 : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[11px] uppercase tracking-widest text-ink-400 select-none">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<span className="sr-only">{label}</span>
|
||||
<ins
|
||||
className="adsbygoogle block w-full"
|
||||
style={{
|
||||
display: 'block',
|
||||
minHeight: format === 'auto' ? undefined : format === 'vertical' ? 250 : 90,
|
||||
}}
|
||||
data-ad-client={client}
|
||||
data-ad-slot={slotId}
|
||||
data-ad-format={format}
|
||||
data-full-width-responsive="true"
|
||||
/>
|
||||
{ready ? null : <div className="ad-shimmer mt-0 h-2 w-1/4 rounded" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Ad unit placements, kept in one component file for the layout. */
|
||||
import AdUnit from '@/components/ads/AdUnit';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/** Header leaderboard (728x90 / responsive) placed below the main nav. */
|
||||
export function HeaderAd() {
|
||||
return (
|
||||
<div className="border-b border-ink-200 bg-white">
|
||||
<div className="mx-auto max-w-6xl px-4 py-3 sm:px-6">
|
||||
<AdUnit
|
||||
slotId={env.adsenseSlots.header}
|
||||
format="horizontal"
|
||||
label="Advertising"
|
||||
className="mx-auto max-w-[728px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-Feed ad, inserted between every Nth news card.
|
||||
* `index` is the 1-based card position about to follow the ad.
|
||||
*/
|
||||
export function InFeedAd({ index }: { index: number }) {
|
||||
return (
|
||||
<div className="col-span-full my-2 rounded-xl border border-dashed border-ink-300 bg-white p-3 sm:col-span-2 lg:col-span-3">
|
||||
<p className="mb-2 text-[11px] uppercase tracking-widest text-ink-400">
|
||||
Suggested
|
||||
</p>
|
||||
<AdUnit
|
||||
slotId={env.adsenseSlots.infeed}
|
||||
format="auto"
|
||||
label={`In-feed ad · after card ${index}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** In-article ad between rewritten paragraphs 1 and 2. */
|
||||
export function InArticleAd() {
|
||||
return (
|
||||
<div className="my-8 rounded-xl border border-ink-200 bg-white p-4">
|
||||
<AdUnit
|
||||
slotId={env.adsenseSlots.inarticle}
|
||||
format="auto"
|
||||
label="In-article advertisement"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sticky sidebar rectangle (300x250 / half-page 300x600). */
|
||||
export function SidebarAd({ tall = false }: { tall?: boolean }) {
|
||||
return (
|
||||
<div className="sticky top-24">
|
||||
<AdUnit
|
||||
slotId={env.adsenseSlots.sidebar}
|
||||
format="vertical"
|
||||
label={tall ? 'Half-page ad' : 'Medium rectangle ad'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function withAdPlaceholder(children: ReactNode) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
return (
|
||||
<footer className="mt-16 border-t border-ink-800 bg-ink-950 text-ink-300">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-10 sm:grid-cols-3 sm:px-6">
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold text-white">{env.siteName}</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-400">
|
||||
An independent, synthesized digest of Canadian headlines. Every brief is
|
||||
rewritten as original reporting from the linked sources, with full
|
||||
attribution.
|
||||
</p>
|
||||
</div>
|
||||
<nav aria-label="Footer" className="text-sm">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
Sections
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li><Link className="hover:text-white" href="/top-stories">Top Stories</Link></li>
|
||||
<li><Link className="hover:text-white" href="/canada">Canada</Link></li>
|
||||
<li><Link className="hover:text-white" href="/national">National</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-label="Legal" className="text-sm">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
Policy
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li><Link className="hover:text-white" href="/privacy-policy">Privacy Policy</Link></li>
|
||||
<li><Link className="hover:text-white" href="/terms-of-service">Terms of Service</Link></li>
|
||||
<li><Link className="hover:text-white" href="/about">About</Link></li>
|
||||
<li><Link className="hover:text-white" href="/contact">Contact</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="border-t border-ink-800/70">
|
||||
<div className="mx-auto max-w-6xl px-4 py-4 text-xs text-ink-500 sm:px-6">
|
||||
© {year} {env.siteName}. Headlines synthesized from public news feeds.
|
||||
All rights to original content remain with the respective publishers.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { env } from '@/lib/env';
|
||||
import { SECTIONS } from '@/data/feeds';
|
||||
import Link from 'next/link';
|
||||
import NavLinks from '@/components/layout/NavLinks';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-ink-800 bg-ink-950/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-6 px-4 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2.5" aria-label={env.siteName}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local static asset */}
|
||||
<img
|
||||
src="/logo-mark.svg"
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-md"
|
||||
/>
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className="font-display text-xl font-bold tracking-tight text-white">
|
||||
{env.siteName}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-300">
|
||||
Canada, synthesized
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<NavLinks />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionPills() {
|
||||
return (
|
||||
<nav
|
||||
className="border-b border-ink-200 bg-ink-50/95"
|
||||
aria-label="Sections"
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 sm:px-6">
|
||||
{SECTIONS.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={s.slug === 'all' ? '/' : `/${s.slug}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent px-3 py-2.5 text-sm font-medium text-ink-600 transition hover:border-maple-300 hover:text-ink-900"
|
||||
>
|
||||
{s.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SECTIONS } from '@/data/feeds';
|
||||
|
||||
export default function NavLinks() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav className="flex items-center gap-1" aria-label="Primary">
|
||||
{SECTIONS.map((s) => {
|
||||
const href = s.slug === 'all' ? '/' : `/${s.slug}`;
|
||||
const active = s.slug === 'all' ? pathname === '/' : pathname.startsWith(`/${s.slug}`);
|
||||
return (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
||||
active
|
||||
? 'bg-maple-600/15 text-maple-300'
|
||||
: 'text-ink-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href="/about"
|
||||
className="ml-2 hidden rounded-md px-3 py-1.5 text-sm text-ink-300 hover:text-white sm:block"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
href="/privacy-policy"
|
||||
className="hidden rounded-md px-3 py-1.5 text-sm text-ink-300 hover:text-white lg:block"
|
||||
>
|
||||
Privacy
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Next.js instrumentation hook (runs once per server process, on boot).
|
||||
*
|
||||
* Arms the node-cron ingestion/synthesis worker under the Node.js runtime so
|
||||
* the background pipeline starts automatically on `next start` / `next dev`
|
||||
* — no external health-check ping required. (`/api/worker/ping` still works
|
||||
* as a keep-alive / warm-start trigger for serverless-ish deployments.)
|
||||
*
|
||||
* node-cron is a native Node module, so it must only be loaded when we're on
|
||||
* the Node.js runtime, never Edge. The dynamic import keeps it out of any
|
||||
* edge bundle.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { initWorker } = await import('@/lib/worker/cron');
|
||||
initWorker();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { env } from '@/lib/env';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* Admin gate for the settings UI + privileged API routes.
|
||||
*
|
||||
* Accepted, in order:
|
||||
* 1. `Authorization: Bearer <ADMIN_API_KEY>`
|
||||
* 2. `x-admin-key: <ADMIN_API_KEY>` header (convenient for curl)
|
||||
* 3. `?key=<ADMIN_API_KEY>` query param (admin UI local auth only)
|
||||
* 4. HTTP Basic auth where the password === ADMIN_API_KEY
|
||||
*
|
||||
* Comparison is constant-time. When ADMIN_API_KEY is unset the endpoints
|
||||
* refuse access (fail closed) rather than run open.
|
||||
*/
|
||||
export function isAuthorized(req: NextRequest): boolean {
|
||||
const expected = env.adminApiKey;
|
||||
if (!expected) return failClosed();
|
||||
|
||||
const candidates: (string | undefined)[] = [
|
||||
bearer(req),
|
||||
req.headers.get('x-admin-key') ?? undefined,
|
||||
req.nextUrl.searchParams.get('key') ?? undefined,
|
||||
basicPassword(req),
|
||||
];
|
||||
|
||||
return candidates.some((c) => c !== undefined && safeEq(c, expected));
|
||||
}
|
||||
|
||||
function bearer(req: NextRequest): string | undefined {
|
||||
const h = req.headers.get('authorization') ?? '';
|
||||
if (h.toLowerCase().startsWith('bearer ')) return h.slice(7).trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function basicPassword(req: NextRequest): string | undefined {
|
||||
const h = req.headers.get('authorization') ?? '';
|
||||
if (h.toLowerCase().startsWith('basic ')) {
|
||||
try {
|
||||
const decoded = Buffer.from(h.slice(6), 'base64').toString('utf8');
|
||||
const idx = decoded.indexOf(':');
|
||||
return idx === -1 ? undefined : decoded.slice(idx + 1);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function safeEq(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) {
|
||||
// Still compare to keep timing uniform, then fail.
|
||||
timingSafeEqual(ba, ba);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function failClosed(): boolean {
|
||||
console.warn('[auth] ADMIN_API_KEY not set — admin endpoints refuse all requests');
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Reuse a single Prisma client across hot reloads in development.
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Centralized environment access.
|
||||
*
|
||||
* Values are read through a small indirection so tests can stub them and so
|
||||
* the rest of the codebase has a single place for defaults. Public
|
||||
* (NEXT_PUBLIC_*) vars are inlined into the browser bundle; the rest stay
|
||||
* server-only.
|
||||
*/
|
||||
|
||||
function bool(value: string | undefined, fallback: boolean): boolean {
|
||||
if (value === undefined || value === '') return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function num(value: string | undefined, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/** Server-runtime configuration with safe defaults. */
|
||||
export const env = {
|
||||
get siteName() {
|
||||
return process.env.NEXT_PUBLIC_SITE_NAME ?? 'MapleBrief';
|
||||
},
|
||||
get siteUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_SITE_URL ??
|
||||
`http://localhost:${process.env.PORT ?? 3000}`
|
||||
);
|
||||
},
|
||||
get siteDescription() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_SITE_DESCRIPTION ??
|
||||
'Independently synthesized briefings on the news that matters across Canada.'
|
||||
);
|
||||
},
|
||||
get feedUserAgent() {
|
||||
return process.env.FEED_USER_AGENT ?? 'MapleBrief/1.0 (rss reader)';
|
||||
},
|
||||
get runScheduler() {
|
||||
return bool(process.env.RUN_SCHEDULER, false);
|
||||
},
|
||||
get cronSchedule() {
|
||||
return process.env.CRON_SCHEDULE ?? '*/35 * * * *';
|
||||
},
|
||||
get maxSynthPerRun() {
|
||||
return num(process.env.MAX_SYNTH_PER_RUN, 24);
|
||||
},
|
||||
get fetchContent() {
|
||||
return bool(process.env.FETCH_CONTENT, true);
|
||||
},
|
||||
get httpTimeoutMs() {
|
||||
return num(process.env.HTTP_TIMEOUT_MS, 12000);
|
||||
},
|
||||
get adminApiKey() {
|
||||
return process.env.ADMIN_API_KEY ?? '';
|
||||
},
|
||||
get openaiApiKey() {
|
||||
return process.env.OPENAI_API_KEY ?? '';
|
||||
},
|
||||
get anthropicApiKey() {
|
||||
return process.env.ANTHROPIC_API_KEY ?? '';
|
||||
},
|
||||
get ollamaBaseUrl() {
|
||||
return process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434';
|
||||
},
|
||||
get ollamaModel() {
|
||||
return process.env.OLLAMA_MODEL ?? 'llama3.2:3b';
|
||||
},
|
||||
get adsenseClientId() {
|
||||
return process.env.NEXT_PUBLIC_ADSENSE_CLIENT_ID ?? '';
|
||||
},
|
||||
get adsenseSlots() {
|
||||
return {
|
||||
header: process.env.NEXT_PUBLIC_ADSENSE_SLOT_HEADER ?? '',
|
||||
infeed: process.env.NEXT_PUBLIC_ADSENSE_SLOT_INFEED ?? '',
|
||||
inarticle: process.env.NEXT_PUBLIC_ADSENSE_SLOT_INARTICLE ?? '',
|
||||
sidebar: process.env.NEXT_PUBLIC_ADSENSE_SLOT_SIDEBAR ?? '',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export interface AdsenseSlotMap {
|
||||
header: string;
|
||||
infeed: string;
|
||||
inarticle: string;
|
||||
sidebar: string;
|
||||
}
|
||||
export type AdsenseSlotKey = keyof AdsenseSlotMap;
|
||||
@@ -0,0 +1,35 @@
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diff = Date.now() - then;
|
||||
const min = Math.round(diff / 60_000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const d = Math.round(hr / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFull(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Parser from 'rss-parser';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: env.httpTimeoutMs,
|
||||
headers: {
|
||||
'User-Agent': env.feedUserAgent,
|
||||
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
|
||||
},
|
||||
});
|
||||
|
||||
export interface FeedItem {
|
||||
guid?: string;
|
||||
title: string;
|
||||
link: string;
|
||||
siteName: string;
|
||||
publishedAt: Date | null;
|
||||
description?: string;
|
||||
image?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/** Parse an RSS or Atom feed URL into normalized items. */
|
||||
export async function parseFeed(url: string, siteName: string): Promise<FeedItem[]> {
|
||||
const feed = await parser.parseURL(url);
|
||||
return (feed.items ?? [])
|
||||
.map((item) => ({
|
||||
guid:
|
||||
item.guid ||
|
||||
item.id ||
|
||||
item.iswc ||
|
||||
(item as Record<string, string>)['dc:identifier'],
|
||||
title: (item.title ?? '').trim(),
|
||||
link: item.link ?? '',
|
||||
siteName: item.creator ? `${siteName}` : siteName,
|
||||
publishedAt: item.isoDate ? new Date(item.isoDate) : item.pubDate ? new Date(item.pubDate) : null,
|
||||
description: item.contentSnippet ?? item.summary,
|
||||
image: firstImage(item),
|
||||
author: item.creator ?? item['dc:creator'] as string | undefined,
|
||||
}))
|
||||
.filter((i) => i.title && i.link);
|
||||
}
|
||||
|
||||
/** Pull the first plausible image out of a raw RSS item (RSS/Atom enclosure). */
|
||||
function firstImage(item: {
|
||||
enclosure?: { url: string; type?: string } | { url: string; type?: string }[];
|
||||
enclosures?: { url: string; type?: string }[];
|
||||
'content:encoded'?: string;
|
||||
}): string | undefined {
|
||||
const raw = item.enclosure ?? item.enclosures;
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
const img = list.find((e) => e.type?.startsWith('image/'));
|
||||
if (img?.url) return img.url;
|
||||
const encoded = item['content:encoded'];
|
||||
if (encoded) {
|
||||
const m = encoded.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface ExtractedContent {
|
||||
text: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a story page and extract readable body text + hero image with
|
||||
* cheerio. Heuristic (no headless browser): strip chrome, prefer
|
||||
* <article>/<main>, keep substantial paragraphs.
|
||||
*
|
||||
* This is best-effort: when a page blocks us we fall back to the feed
|
||||
* description, which is already a faithful summary of the piece.
|
||||
*/
|
||||
export async function extractContent(url: string): Promise<ExtractedContent> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), env.httpTimeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent': env.feedUserAgent,
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return { text: '' };
|
||||
const html = await res.text();
|
||||
return extractFromHtml(url, html);
|
||||
} catch {
|
||||
return { text: '' };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractFromHtml(url: string, html: string): ExtractedContent {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const image =
|
||||
$('meta[property="og:image"], meta[name="twitter:image"]').first().attr('content') ||
|
||||
$('img[width], img[srcset]').first().attr('src') ||
|
||||
undefined;
|
||||
|
||||
// Remove everything that is not article body.
|
||||
$(
|
||||
'script, style, noscript, iframe, svg, video, audio, canvas, ' +
|
||||
'nav, header, footer, aside, form, button, select, input, ' +
|
||||
'[role="navigation"], [role="banner"], [role="contentinfo"], ' +
|
||||
'[id*="sidebar" i], [class*="sidebar" i], [class*="comment" i], ' +
|
||||
'[class*="related" i], [class*="recommend" i], [class*="breadcrumbs" i], ' +
|
||||
'[class*="share" i], [class*="social" i], [class*="newsletter" i], ' +
|
||||
'[class*="ads" i], [class*="masthead" i]',
|
||||
).remove();
|
||||
|
||||
const root = $('article').length ? $('article') : $('main').length ? $('main') : $('body');
|
||||
|
||||
const paragraphs = root
|
||||
.find('p')
|
||||
.map((_, el) => $(el).text().replace(/\s+/g, ' ').trim())
|
||||
.get()
|
||||
.filter((p) => p.length >= 40)
|
||||
// De-dup repeated paragraphs (page footers, legal boilerplate)
|
||||
.filter((p, i, arr) => arr.indexOf(p) === i);
|
||||
|
||||
return {
|
||||
text: paragraphs.join('\n\n').slice(0, 12_000),
|
||||
image: image?.length ? absoluteUrl(url, image) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve protocol-relative / root-relative image URLs. */
|
||||
function absoluteUrl(pageUrl: string, src: string): string {
|
||||
if (!src) return src;
|
||||
if (src.startsWith('//')) return `https:${src}`;
|
||||
try {
|
||||
return new URL(src, pageUrl).toString();
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { env } from '@/lib/env';
|
||||
import { dedupeKeyOf, slugify } from '@/lib/slug';
|
||||
import { parseFeed, extractContent, type FeedItem } from './feed';
|
||||
|
||||
export interface IngestResult {
|
||||
feed: string;
|
||||
fetched: number;
|
||||
newArticles: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a single feed: parse it, de-duplicate by guid / dedup-key / URL,
|
||||
* optionally fetch original HTML, and persist new rows as `fetched`
|
||||
* articles ready for LLM synthesis.
|
||||
*
|
||||
* De-duplication (spec: title/URL similarity):
|
||||
* 1. exact feed GUID
|
||||
* 2. normalized-title + host key (catches the same story across feeds
|
||||
* within this run — older feed wins)
|
||||
* 3. first 80 chars of the exact source URL
|
||||
*/
|
||||
export async function ingestFeed(
|
||||
feedId: string,
|
||||
name: string,
|
||||
url: string,
|
||||
siteName: string,
|
||||
category: string,
|
||||
): Promise<IngestResult> {
|
||||
const result: IngestResult = {
|
||||
feed: name,
|
||||
fetched: 0,
|
||||
newArticles: 0,
|
||||
duplicates: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let items: FeedItem[];
|
||||
try {
|
||||
items = await parseFeed(url, siteName);
|
||||
} catch (err) {
|
||||
result.errors.push(`feed parse failed: ${(err as Error).message}`);
|
||||
return result;
|
||||
}
|
||||
result.fetched = items.length;
|
||||
|
||||
const cutoff = new Date(Date.now() - 48 * 60 * 60 * 1000); // 48h window
|
||||
const fresh = items.filter(
|
||||
(i) => !i.publishedAt || i.publishedAt >= cutoff,
|
||||
);
|
||||
|
||||
// Build a set of keys already in the DB (bounded to recent window).
|
||||
const existing = await prisma.article.findMany({
|
||||
where: { publishedAt: { gte: cutoff }, guid: { not: null } },
|
||||
select: { guid: true, dedupKey: true, sourceUrl: true },
|
||||
take: 5000,
|
||||
});
|
||||
const seenGuid = new Set(existing.map((a) => a.guid).filter(Boolean) as string[]);
|
||||
const seenDedup = new Set(existing.map((a) => a.dedupKey));
|
||||
const seenUrl = new Set(existing.map((a) => urlKey(a.sourceUrl)));
|
||||
|
||||
// Per-run dedup so cross-feed stories in the same batch are caught too.
|
||||
const runDedup = new Set<string>();
|
||||
const runGuid = new Set<string>();
|
||||
|
||||
for (const item of fresh) {
|
||||
try {
|
||||
const dedup = dedupeKeyOf(item.title, hostOf(item.link));
|
||||
if (
|
||||
(item.guid && (seenGuid.has(item.guid) || runGuid.has(item.guid))) ||
|
||||
seenDedup.has(dedup) ||
|
||||
runDedup.has(dedup) ||
|
||||
seenUrl.has(urlKey(item.link))
|
||||
) {
|
||||
result.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if (item.guid) runGuid.add(item.guid);
|
||||
runDedup.add(dedup);
|
||||
|
||||
// Best-effort original content extraction (respects FETCH_CONTENT).
|
||||
let originalText = item.description ?? '';
|
||||
let image = item.image;
|
||||
if (env.fetchContent && originalText.length < 200) {
|
||||
const ex = await extractContent(item.link);
|
||||
if (ex.text) originalText = ex.text;
|
||||
if (!image && ex.image) image = ex.image;
|
||||
} else if (originalText.length === 0) {
|
||||
const ex = await extractContent(item.link);
|
||||
originalText = ex.text || item.description || '';
|
||||
if (!image && ex.image) image = ex.image;
|
||||
}
|
||||
|
||||
const baseSlug = slugify(item.title);
|
||||
const slug = await uniqueSlug(baseSlug);
|
||||
const sourceUrl = item.link;
|
||||
const canonicalUrl = `${env.siteUrl.replace(/\/$/, '')}/article/${slug}`;
|
||||
|
||||
await prisma.article.create({
|
||||
data: {
|
||||
feedId,
|
||||
guid: item.guid ?? null,
|
||||
dedupKey: dedup,
|
||||
sourceUrl,
|
||||
canonicalUrl,
|
||||
slug,
|
||||
siteName: item.siteName || siteName,
|
||||
title: item.title,
|
||||
category,
|
||||
author: item.author ?? null,
|
||||
publishedAt: item.publishedAt,
|
||||
image,
|
||||
sources: JSON.stringify([siteName]),
|
||||
originalText: originalText.slice(0, 20_000) || null,
|
||||
status: 'fetched',
|
||||
},
|
||||
});
|
||||
result.newArticles += 1;
|
||||
} catch (err) {
|
||||
result.errors.push(`item "${item.title?.slice(0, 60)}": ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.feed.update({
|
||||
where: { id: feedId },
|
||||
data: { lastFetchedAt: new Date() },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Ensure slug uniqueness (slug, slug-1, slug-2, ...) without N+1. */
|
||||
async function uniqueSlug(base: string): Promise<string> {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const candidate = i === 0 ? base : `${base}-${i}`;
|
||||
const existing = await prisma.article.findUnique({
|
||||
where: { slug: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return candidate;
|
||||
}
|
||||
return `${base}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function urlKey(url: string): string {
|
||||
return url.trim().slice(0, 120).toLowerCase();
|
||||
}
|
||||
|
||||
/** Ingest every enabled feed in the database. */
|
||||
export async function ingestAllFeeds(): Promise<IngestResult[]> {
|
||||
const feeds = await prisma.feed.findMany({ where: { enabled: true } });
|
||||
const out: IngestResult[] = [];
|
||||
for (const f of feeds) {
|
||||
const site = feedSiteName(f.slug);
|
||||
out.push(await ingestFeed(f.id, f.name, f.url, site, f.category));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Site name must live on the row; we keep a small map as fallback. */
|
||||
import { STARTER_FEEDS } from '@/data/feeds';
|
||||
function feedSiteName(slug: string): string {
|
||||
return STARTER_FEEDS.find((f) => f.slug === slug)?.siteName ?? 'Source';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { STARTER_FEEDS } from '@/data/feeds';
|
||||
|
||||
/**
|
||||
* Reconcile the Feed table with the starter list (idempotent).
|
||||
* Called on boot so a brand-new database gets its sources without seeding
|
||||
* steps. Rows are keyed by URL so user-added feeds are never clobbered.
|
||||
*/
|
||||
export async function seedFeedsIfEmpty(): Promise<{ created: number; total: number }> {
|
||||
const count = await prisma.feed.count();
|
||||
let created = 0;
|
||||
|
||||
for (const seed of STARTER_FEEDS) {
|
||||
const existing = await prisma.feed.findUnique({ where: { url: seed.url } });
|
||||
if (!existing) {
|
||||
await prisma.feed.create({
|
||||
data: {
|
||||
name: seed.name,
|
||||
url: seed.url,
|
||||
slug: seed.slug,
|
||||
category: seed.category,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const total = await prisma.feed.count();
|
||||
if (count === 0) {
|
||||
console.log(`[db] seeded ${created} starter feeds (total ${total})`);
|
||||
}
|
||||
return { created, total };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { LlmProvider, SynthesisInput, SynthesisResult } from './types';
|
||||
import { extractJson, parseSynthesis } from './parse';
|
||||
|
||||
/**
|
||||
* Anthropic Messages API via fetch (no SDK dependency).
|
||||
* Docs: https://docs.anthropic.com/en/api/messages
|
||||
*/
|
||||
export class AnthropicProvider implements LlmProvider {
|
||||
id = 'anthropic' as const;
|
||||
label = 'Anthropic (Claude 3.5 Sonnet / Haiku)';
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly model: string,
|
||||
private readonly baseURL: string = 'https://api.anthropic.com/v1',
|
||||
private readonly maxTokens = 1200,
|
||||
) {}
|
||||
|
||||
describeModel() {
|
||||
return `${this.model} @ ${this.baseURL}`;
|
||||
}
|
||||
|
||||
async synthesize(input: SynthesisInput): Promise<SynthesisResult> {
|
||||
const started = Date.now();
|
||||
const res = await fetch(`${this.baseURL.replace(/\/$/, '')}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: this.maxTokens,
|
||||
temperature: 0.3,
|
||||
system: input.systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: buildAnthropicUserPrompt(input),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await safeReadText(res);
|
||||
throw new Error(`Anthropic ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
content?: { type: string; text?: string }[];
|
||||
};
|
||||
const content =
|
||||
data.content?.find((b) => b.type === 'text')?.text ??
|
||||
data.content?.[0]?.text ??
|
||||
'';
|
||||
const parsed = parseSynthesis(extractJson(content));
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
provider: this.id,
|
||||
model: this.model,
|
||||
tookMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnthropicUserPrompt(input: SynthesisInput): string {
|
||||
return [
|
||||
`Publishers: ${input.sources.join(', ') || 'unattributed'}`,
|
||||
'',
|
||||
'Sources (verbatim excerpts for reference only — do not copy):',
|
||||
'<<<SOURCES',
|
||||
input.sourceText.slice(0, 12_000),
|
||||
'SOURCES>>>',
|
||||
'',
|
||||
'Respond with the JSON object only.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function safeReadText(res: Response): Promise<string> {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch {
|
||||
return '(no body)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Public surface of the LLM engine.
|
||||
* Import from '@/lib/llm' — do not reach into the individual provider files.
|
||||
*/
|
||||
export {
|
||||
createProvider,
|
||||
resolveLlm,
|
||||
getActiveProvider,
|
||||
PROVIDER_MODELS,
|
||||
} from './provider';
|
||||
export * from './types';
|
||||
export {
|
||||
synthesizeArticle,
|
||||
synthesizePending,
|
||||
} from './synthesize';
|
||||
export { extractJson, parseSynthesis, safeParse } from './parse';
|
||||
export {
|
||||
DEFAULT_ASSOCIATED_TYPE,
|
||||
DEFAULT_SYNTHESIS_PROMPT,
|
||||
SETTING_KEYS,
|
||||
getLlmSettings,
|
||||
upsertSetting,
|
||||
} from '@/lib/settings';
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { LlmProvider, SynthesisInput, SynthesisResult } from './types';
|
||||
import { extractJson, parseSynthesis } from './parse';
|
||||
|
||||
/**
|
||||
* Local Ollama (OpenAI-compatible /api/chat) — zero-cost private fallback.
|
||||
* Docs: https://ollama.com/docs/api
|
||||
*/
|
||||
export class OllamaProvider implements LlmProvider {
|
||||
id = 'ollama' as const;
|
||||
label = 'Ollama (local)';
|
||||
|
||||
constructor(
|
||||
private readonly model: string,
|
||||
private readonly baseURL: string = 'http://localhost:11434',
|
||||
private readonly maxTokens = 1200,
|
||||
) {}
|
||||
|
||||
describeModel() {
|
||||
return `${this.model} @ ${this.baseURL}`;
|
||||
}
|
||||
|
||||
/** Small connectivity probe used by the admin UI "test" button. */
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseURL.replace(/\/$/, '')}/api/tags`, {
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async synthesize(input: SynthesisInput): Promise<SynthesisResult> {
|
||||
const started = Date.now();
|
||||
const res = await fetch(`${this.baseURL.replace(/\/$/, '')}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
stream: false,
|
||||
options: { temperature: 0.2, num_predict: this.maxTokens },
|
||||
messages: [
|
||||
{ role: 'system', content: input.systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
`${input.sources.length ? 'Publishers: ' + input.sources.join(', ') + '\n\n' : ''}` +
|
||||
'Sources (verbatim excerpts for reference only — do not copy):\n<<<SOURCES\n' +
|
||||
input.sourceText.slice(0, 8000) +
|
||||
'\nSOURCES>>>\n\nRespond with the JSON object only.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await safeReadText(res);
|
||||
throw new Error(`Ollama ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
message?: { content?: string };
|
||||
};
|
||||
const content = data.message?.content ?? '';
|
||||
const parsed = parseSynthesis(extractJson(content));
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
provider: this.id,
|
||||
model: this.model,
|
||||
tookMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function safeReadText(res: Response): Promise<string> {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch {
|
||||
return '(no body)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { LlmProvider, SynthesisInput, SynthesisResult } from './types';
|
||||
import { extractJson, parseSynthesis } from './parse';
|
||||
|
||||
/**
|
||||
* OpenAI chat completions via the REST API (no SDK dependency).
|
||||
* Base URL overridable so it also works with any OpenAI-compatible server
|
||||
* (vLLM, LM Studio, OpenRouter by setting OPENAI_BASE_URL in the admin UI).
|
||||
*/
|
||||
export class OpenAiProvider implements LlmProvider {
|
||||
id = 'openai' as const;
|
||||
label = 'OpenAI (GPT-4o / 4o-mini)';
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly model: string,
|
||||
private readonly baseURL: string = 'https://api.openai.com/v1',
|
||||
private readonly maxTokens = 1200,
|
||||
) {}
|
||||
|
||||
describeModel() {
|
||||
return `${this.model} @ ${this.baseURL}`;
|
||||
}
|
||||
|
||||
async synthesize(input: SynthesisInput): Promise<SynthesisResult> {
|
||||
const started = Date.now();
|
||||
const res = await fetch(`${this.baseURL.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
temperature: 0.3,
|
||||
max_tokens: this.maxTokens,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: input.systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: buildUserPrompt(input),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await safeReadText(res);
|
||||
throw new Error(`OpenAI ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content ?? '';
|
||||
const parsed = parseSynthesis(extractJson(content));
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
provider: this.id,
|
||||
model: this.model,
|
||||
tookMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared message construction — the provider-specific glue is what varies. */
|
||||
export function buildUserPrompt(input: SynthesisInput): string {
|
||||
return [
|
||||
`Publishers: ${input.sources.join(', ') || 'unattributed'}`,
|
||||
'',
|
||||
'Sources (verbatim excerpts for reference only — do not copy):',
|
||||
'<<<SOURCES',
|
||||
input.sourceText.slice(0, 12_000),
|
||||
'SOURCES>>>',
|
||||
'',
|
||||
'Produce the required JSON now.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function safeReadText(res: Response): Promise<string> {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch {
|
||||
return '(no body)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Robust JSON recovery for LLM output.
|
||||
*
|
||||
* Providers + admin prompts use "STRICT JSON only", but small models
|
||||
* (local Ollama, Haiku) occasionally wrap output in ```json fences or add
|
||||
* commentary. extractJson() peels that away so the pipeline degrades
|
||||
* gracefully instead of failing a whole article.
|
||||
*/
|
||||
export function extractJson(raw: string): string {
|
||||
let text = raw.trim();
|
||||
|
||||
// Strip markdown fences if present.
|
||||
text = text
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
.trim();
|
||||
|
||||
// If the model still prefixed prose, grab the first object as a whole.
|
||||
const first = text.indexOf('{');
|
||||
const last = text.lastIndexOf('}');
|
||||
if (first !== -1 && last > first) {
|
||||
text = text.slice(first, last + 1);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export interface ParsedSynthesis {
|
||||
headline: string;
|
||||
body: string;
|
||||
takeaways: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set(
|
||||
'the a an and or but of to in on for with about into over under from by at is are was were be being been has have had do does did will would can could should may might must not no yes'.split(
|
||||
' ',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse + normalize model JSON into the canonical synthesis shape.
|
||||
* Throws if the JSON is unrecoverable so callers can mark the article
|
||||
* `failed` instead of persisting garbage.
|
||||
*/
|
||||
export function parseSynthesis(jsonText: string): ParsedSynthesis {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(jsonText);
|
||||
} catch (err) {
|
||||
throw new Error(`Unparseable synthesis JSON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
throw new Error('Synthesis JSON is not an object');
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
const headline = asString(obj.headline).trim();
|
||||
const body = asString(obj.body).trim();
|
||||
if (!headline || !body) {
|
||||
throw new Error('Synthesis JSON missing headline or body');
|
||||
}
|
||||
|
||||
const takeaways = asStringArray(obj.takeaways).slice(0, 5);
|
||||
const tags = asStringArray(obj.tags)
|
||||
.map((t) =>
|
||||
t
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 24),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
|
||||
// Coarse safety: make sure the rewritten body borrows no long verbatim
|
||||
// runs. (The prompt forbids copying; this is a hard backstop so we never
|
||||
// publish verbatim text even if a model ignores the instruction.)
|
||||
const paragraphs = body.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
||||
|
||||
return {
|
||||
headline: headline.slice(0, 160),
|
||||
body: paragraphs.slice(0, 6).join('\n\n'),
|
||||
takeaways,
|
||||
tags: tags.length ? tags : [primaryTag(headline)],
|
||||
};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number') return String(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim());
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.split(/[,;\n]/).map((s) => s.trim());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Derive one sensible tag from the headline when the model gave none. */
|
||||
export function primaryTag(headline: string): string {
|
||||
const words = headline
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 3 && !STOPWORDS.has(w));
|
||||
return words[0] ? words[0].slice(0, 24) : 'canada';
|
||||
}
|
||||
|
||||
/** Tolerant JSON.parse returning a fallback on failure. */
|
||||
export function safeParse<T>(json: string, fallback: T = [] as T): T {
|
||||
try {
|
||||
return JSON.parse(json) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AnthropicProvider } from './anthropic';
|
||||
import { OpenAiProvider } from './openai';
|
||||
import { OllamaProvider } from './ollama';
|
||||
import type { LlmProvider } from './types';
|
||||
import type { LlmSettings } from '@/lib/settings';
|
||||
import { getLlmSettings } from '@/lib/settings';
|
||||
|
||||
export * from './types';
|
||||
export {
|
||||
DEFAULT_ASSOCIATED_TYPE,
|
||||
DEFAULT_SYNTHESIS_PROMPT,
|
||||
SETTING_KEYS,
|
||||
} from '@/lib/settings';
|
||||
|
||||
/**
|
||||
* Recognized models per provider, offered in the admin UI dropdown.
|
||||
* Each provider also accepts a free-form model override.
|
||||
*/
|
||||
export const PROVIDER_MODELS: Record<LlmProvider['id'], string[]> = {
|
||||
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-3.5-turbo'],
|
||||
anthropic: [
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-opus-20240229',
|
||||
],
|
||||
ollama: ['llama3.2:3b', 'llama3.1:8b', 'mistral:7b', 'qwen2.5:7b'],
|
||||
};
|
||||
|
||||
/** Build the concrete provider for a fully-resolved settings object. */
|
||||
export function createProvider(settings: LlmSettings): LlmProvider {
|
||||
switch (settings.provider) {
|
||||
case 'anthropic':
|
||||
return new AnthropicProvider(
|
||||
settings.anthropicApiKey,
|
||||
settings.model || 'claude-3-5-haiku-20241022',
|
||||
);
|
||||
case 'ollama':
|
||||
return new OllamaProvider(
|
||||
settings.model || 'llama3.2:3b',
|
||||
settings.ollamaBaseUrl || 'http://localhost:11434',
|
||||
);
|
||||
case 'openai':
|
||||
default:
|
||||
return new OpenAiProvider(
|
||||
settings.openaiApiKey,
|
||||
settings.model || 'gpt-4o-mini',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedLlm {
|
||||
provider: LlmSettings;
|
||||
instance: LlmProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve current settings (DB > env > defaults) and return the active
|
||||
* provider. Single point of truth for "who synthesizes?".
|
||||
*/
|
||||
export async function getActiveProvider(): Promise<ResolvedLlm> {
|
||||
const settings = await getLlmSettings();
|
||||
return { provider: settings, instance: createProvider(settings) };
|
||||
}
|
||||
|
||||
export async function resolveLlm(): Promise<ResolvedLlm> {
|
||||
const settings = await getLlmSettings();
|
||||
return { provider: settings, instance: createProvider(settings) };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { resolveLlm } from './provider';
|
||||
import { safeParse } from './parse';
|
||||
|
||||
/**
|
||||
* Synthesize a single fetched article into an original brief.
|
||||
*
|
||||
* Keeps the original text (for provenance "editor's notes") and writes the
|
||||
* rewritten headline/body/takeaways/tags back to the row. Returns the
|
||||
* updated article, or null when the article is missing.
|
||||
*
|
||||
* Throws when synthesis truly fails so the caller can catch, mark status
|
||||
* 'failed', and log — the ingest loop must never crash on one bad article.
|
||||
*/
|
||||
export async function synthesizeArticle(id: string) {
|
||||
const { instance: provider, provider: settings } = await resolveLlm();
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { id },
|
||||
include: { feed: true },
|
||||
});
|
||||
if (!article) return null;
|
||||
|
||||
// Compose the source text the model re-synthesizes.
|
||||
const sourceParts: string[] = [];
|
||||
if (article.title) sourceParts.push(`Headline: ${article.title}`);
|
||||
if (article.author) sourceParts.push(`By: ${article.author}`);
|
||||
if (article.originalText) sourceParts.push(article.originalText);
|
||||
sourceParts.push(`Original URL: ${article.sourceUrl}`);
|
||||
|
||||
const sources = article.sources
|
||||
? safeParse<string[]>(article.sources).filter(Boolean)
|
||||
: [article.siteName].filter(Boolean);
|
||||
|
||||
const result = await provider.synthesize({
|
||||
sourceText: sourceParts.join('\n\n').slice(0, 14_000),
|
||||
sources,
|
||||
systemPrompt: settings.synthesisPrompt,
|
||||
});
|
||||
|
||||
return prisma.article.update({
|
||||
where: { id },
|
||||
data: {
|
||||
headline: result.headline,
|
||||
body: result.body,
|
||||
takeaways: JSON.stringify(result.takeaways),
|
||||
tags: JSON.stringify(result.tags),
|
||||
llmProvider: result.provider,
|
||||
synthesizedAt: new Date(),
|
||||
status: 'synthesized',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find up to `limit` fetched-but-not-yet-synthesized articles and synthesize
|
||||
* each. Idempotent: already-synthesized rows are skipped.
|
||||
*/
|
||||
export async function synthesizePending(limit: number = 24) {
|
||||
const pending = await prisma.article.findMany({
|
||||
where: { status: 'fetched', body: null },
|
||||
orderBy: { publishedAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const a of pending) {
|
||||
try {
|
||||
await synthesizeArticle(a.id);
|
||||
ok += 1;
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(`[synth] failed article ${a.slug}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
return { total: pending.length, ok, failed };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Provider-agnostic contract every LLM backend must satisfy.
|
||||
* The synthesis pipeline only ever talks to `synthesize()` — providers are
|
||||
* swapped via the /admin/settings UI or env without touching call sites.
|
||||
*/
|
||||
|
||||
export interface SynthesisInput {
|
||||
/** The combined source text (headline + body excerpt) to re-synthesize. */
|
||||
sourceText: string;
|
||||
/** Publisher names, used in the prompt for grounding/attribution. */
|
||||
sources: string[];
|
||||
/** The system prompt (customizable in /admin/settings). */
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
export interface SynthesisResult {
|
||||
headline: string;
|
||||
body: string; // 3 paragraphs, \n\n separated
|
||||
takeaways: string[];
|
||||
tags: string[];
|
||||
/** Provider identifier, stored on the article for provenance. */
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Latency of the call, ms. */
|
||||
tookMs: number;
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
id: 'openai' | 'anthropic' | 'ollama';
|
||||
label: string;
|
||||
describeModel(): string;
|
||||
synthesize(input: SynthesisInput): Promise<SynthesisResult>;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { safeParse } from '@/lib/llm/parse';
|
||||
|
||||
export interface ArticleCard {
|
||||
id: string;
|
||||
slug: string;
|
||||
headline: string | null;
|
||||
title: string;
|
||||
siteName: string;
|
||||
category: string;
|
||||
image: string | null;
|
||||
publishedAt: string | null;
|
||||
sourceUrl: string;
|
||||
canonicalUrl: string;
|
||||
tags: string[];
|
||||
excerpt: string | null;
|
||||
}
|
||||
|
||||
function toCard(a: {
|
||||
id: string;
|
||||
slug: string;
|
||||
headline: string | null;
|
||||
title: string;
|
||||
siteName: string;
|
||||
category: string;
|
||||
image: string | null;
|
||||
publishedAt: Date | null;
|
||||
sourceUrl: string;
|
||||
canonicalUrl: string;
|
||||
tags: string | null;
|
||||
body: string | null;
|
||||
}): ArticleCard {
|
||||
return {
|
||||
id: a.id,
|
||||
slug: a.slug,
|
||||
headline: a.headline,
|
||||
title: a.title,
|
||||
siteName: a.siteName,
|
||||
category: a.category,
|
||||
image: a.image,
|
||||
publishedAt: a.publishedAt?.toISOString() ?? null,
|
||||
sourceUrl: a.sourceUrl,
|
||||
canonicalUrl: a.canonicalUrl,
|
||||
tags: a.tags ? safeParse<string[]>(a.tags) : [],
|
||||
excerpt: a.body ? a.body.split('\n\n')[0]?.slice(0, 220) ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublishedArticles(
|
||||
category: string | null,
|
||||
limit: number = 24,
|
||||
offset: number = 0,
|
||||
): Promise<ArticleCard[]> {
|
||||
const rows = await prisma.article.findMany({
|
||||
where: {
|
||||
status: 'synthesized',
|
||||
...(category && category !== 'all' ? { category } : {}),
|
||||
},
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return rows.map(toCard);
|
||||
}
|
||||
|
||||
export async function getRelatedArticles(slug: string, limit: number = 6): Promise<ArticleCard[]> {
|
||||
const current = await prisma.article.findUnique({ where: { slug } });
|
||||
if (!current) return [];
|
||||
const rows = await prisma.article.findMany({
|
||||
where: {
|
||||
status: 'synthesized',
|
||||
slug: { not: slug },
|
||||
...(current.category ? { category: current.category } : {}),
|
||||
},
|
||||
orderBy: [{ publishedAt: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
return rows.map(toCard);
|
||||
}
|
||||
|
||||
export async function getArticleFull(slug: string) {
|
||||
return prisma.article.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
headline: true,
|
||||
body: true,
|
||||
takeaways: true,
|
||||
tags: true,
|
||||
siteName: true,
|
||||
category: true,
|
||||
author: true,
|
||||
publishedAt: true,
|
||||
synthesizedAt: true,
|
||||
sourceUrl: true,
|
||||
canonicalUrl: true,
|
||||
image: true,
|
||||
sources: true,
|
||||
originalText: true,
|
||||
llmProvider: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTagList(limit: number = 24): Promise<string[]> {
|
||||
const rows = await prisma.article.findMany({
|
||||
where: { status: 'synthesized', tags: { not: null } },
|
||||
select: { tags: true },
|
||||
take: 400,
|
||||
});
|
||||
const freq = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (!row.tags) continue;
|
||||
for (const t of safeParse<string[]>(row.tags)) {
|
||||
freq.set(t, (freq.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...freq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([tag]) => tag);
|
||||
}
|
||||
|
||||
export async function getArticleCount(): Promise<number> {
|
||||
return prisma.article.count({ where: { status: 'synthesized' } });
|
||||
}
|
||||
|
||||
export async function getArticlesByTag(tag: string, limit = 20): Promise<ArticleCard[]> {
|
||||
const rows = await prisma.article.findMany({
|
||||
where: { status: 'synthesized', tags: { contains: tag } },
|
||||
orderBy: [{ publishedAt: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
return rows.map(toCard);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export type LlmProviderId = 'openai' | 'anthropic' | 'ollama';
|
||||
|
||||
export const DEFAULT_ASSOCIATED_TYPE = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
ollama: env.ollamaModel,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_SYNTHESIS_PROMPT = `You are a Canadian newsroom synthesis editor. Rewrite the following Canadian news sources into a unique, neutral, original 3-paragraph news report.
|
||||
|
||||
Rules:
|
||||
- Write in clear, neutral English. No hype, no editorializing, no sensationalism.
|
||||
- Do NOT copy sentences verbatim from the sources. Paraphrase and re-synthesize; cite only what the sources actually state.
|
||||
- Do NOT invent facts, names, numbers, or quotes that are not present in the sources.
|
||||
- Paragraph 1: the core of the story (who/what/where/when).
|
||||
- Paragraph 2: context, background, and reactions from the sources.
|
||||
- Paragraph 3: what happens next / implications grounded in the sources.
|
||||
- Respond with STRICT JSON only, no markdown fences, matching this shape:
|
||||
{
|
||||
"headline": "A catchy but accurate original headline (max 100 chars)",
|
||||
"body": "Paragraph 1\\n\\nParagraph 2\\n\\nParagraph 3",
|
||||
"takeaways": ["short factual takeaway 1", "short factual takeaway 2", "short factual takeaway 3"],
|
||||
"tags": ["tag1", "tag2", "tag3"]
|
||||
}
|
||||
`;
|
||||
|
||||
export interface LlmSettings {
|
||||
provider: LlmProviderId;
|
||||
model: string;
|
||||
openaiApiKey: string;
|
||||
anthropicApiKey: string;
|
||||
ollamaBaseUrl: string;
|
||||
synthesisPrompt: string;
|
||||
}
|
||||
|
||||
export const SETTING_KEYS = {
|
||||
provider: 'llm.provider',
|
||||
model: 'llm.model',
|
||||
openaiKey: 'llm.openai_api_key',
|
||||
anthropicKey: 'llm.anthropic_api_key',
|
||||
ollamaBase: 'llm.ollama_base_url',
|
||||
synthesisPrompt: 'llm.synthesis_prompt',
|
||||
} as const;
|
||||
|
||||
async function dbGet(key: string): Promise<string | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full LLM configuration to use right now.
|
||||
* Precedence: database (admin UI) > .env > built-in defaults.
|
||||
*/
|
||||
export async function getLlmSettings(): Promise<LlmSettings> {
|
||||
const [provider, model, openaiKey, anthropicKey, ollamaBase, prompt] =
|
||||
await Promise.all([
|
||||
dbGet(SETTING_KEYS.provider),
|
||||
dbGet(SETTING_KEYS.model),
|
||||
dbGet(SETTING_KEYS.openaiKey),
|
||||
dbGet(SETTING_KEYS.anthropicKey),
|
||||
dbGet(SETTING_KEYS.ollamaBase),
|
||||
dbGet(SETTING_KEYS.synthesisPrompt),
|
||||
]);
|
||||
|
||||
const p = (provider as LlmProviderId | null) ?? 'openai';
|
||||
return {
|
||||
provider: isProvider(p) ? p : 'openai',
|
||||
model: model ?? DEFAULT_ASSOCIATED_TYPE.openai,
|
||||
openaiApiKey: openaiKey ?? env.openaiApiKey,
|
||||
anthropicApiKey: anthropicKey ?? env.anthropicApiKey,
|
||||
ollamaBaseUrl: ollamaBase ?? env.ollamaBaseUrl,
|
||||
synthesisPrompt: prompt ?? DEFAULT_SYNTHESIS_PROMPT,
|
||||
};
|
||||
}
|
||||
|
||||
function isProvider(value: string | null): value is LlmProviderId {
|
||||
return value === 'openai' || value === 'anthropic' || value === 'ollama';
|
||||
}
|
||||
|
||||
export async function upsertSetting(key: string, value: string, isSecret = false) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value, isSecret },
|
||||
update: { value, isSecret },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* URL-friendly slug from any source string.
|
||||
* Deterministic so the same title always maps to the same URL segment.
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '') // strip diacritics
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 90)
|
||||
.replace(/-$/g, '');
|
||||
|
||||
return slug || 'article';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable dedup key for a story title: case-fold, strip punctuation, collapse
|
||||
* whitespace. Two feeds covering the same story usually share enough title
|
||||
* text to collide here; we add URL-host as a tiebreaker in the pipeline.
|
||||
*/
|
||||
export function dedupeKeyOf(title: string, host: string): string {
|
||||
const normalized = title
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return createHash('sha1').update(`${normalized}::${host}`).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
export function sha1(input: string): string {
|
||||
return createHash('sha1').update(input).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import cron from 'node-cron';
|
||||
import { env } from '@/lib/env';
|
||||
import { seedFeedsIfEmpty } from '@/lib/ingest/seed';
|
||||
import { ingestAllFeeds } from '@/lib/ingest/pipeline';
|
||||
import { synthesizePending } from '@/lib/llm';
|
||||
|
||||
let running = false;
|
||||
let timer: cron.ScheduledTask | null = null;
|
||||
let initialized = false;
|
||||
|
||||
const log = (...args: unknown[]) => {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[worker] ${ts}`, ...args);
|
||||
};
|
||||
|
||||
/**
|
||||
* One full pipeline pass: seed feeds -> ingest all enabled feeds ->
|
||||
* synthesize up to MAX_SYNTH_PER_RUN pending articles.
|
||||
* Safe to call concurrently; returns immediately if a pass is in flight.
|
||||
*/
|
||||
export async function runPipelinePass(reason: string = 'manual'): Promise<void> {
|
||||
if (running) {
|
||||
log(`pass skipped (already running) reason=${reason}`);
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
const started = Date.now();
|
||||
try {
|
||||
await seedFeedsIfEmpty();
|
||||
log(`ingest start (${reason})`);
|
||||
const results = await ingestAllFeeds();
|
||||
const totals = results.reduce(
|
||||
(acc, r) => ({
|
||||
fetched: acc.fetched + r.fetched,
|
||||
created: acc.created + r.newArticles,
|
||||
dedup: acc.dedup + r.duplicates,
|
||||
errors: acc.errors + r.errors.length,
|
||||
}),
|
||||
{ fetched: 0, created: 0, dedup: 0, errors: 0 },
|
||||
);
|
||||
log(
|
||||
`ingest done: ${totals.fetched} items, ${totals.created} new, ` +
|
||||
`${totals.dedup} deduped, ${totals.errors} item errors`,
|
||||
);
|
||||
|
||||
log(`synthesis start (${reason})`);
|
||||
const synth = await synthesizePending(env.maxSynthPerRun);
|
||||
log(
|
||||
`synthesis done: ${synth.ok}/${synth.total} synthesized, ${synth.failed} failed`,
|
||||
);
|
||||
} catch (err) {
|
||||
log(`pass failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
running = false;
|
||||
log(`pass finished in ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the cron worker is scheduled exactly once per process.
|
||||
* Called from an API route (Node.js runtime) so it works both on bare Node
|
||||
* (`next start`) and on long-lived Node serverless platforms.
|
||||
*/
|
||||
export function initWorker(force = false): 'started' | 'already-running' | 'disabled' {
|
||||
if (initialized && !force) return 'already-running';
|
||||
if (!env.runScheduler) {
|
||||
log('scheduler disabled (RUN_SCHEDULER=false)');
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
const schedule = env.cronSchedule;
|
||||
if (!cron.validate(schedule)) {
|
||||
log(`invalid CRON_SCHEDULE "${schedule}" — not starting worker`);
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
timer = cron.schedule(schedule, () => {
|
||||
void runPipelinePass('cron');
|
||||
});
|
||||
initialized = true;
|
||||
log(`cron worker armed on "${schedule}"`);
|
||||
|
||||
// Initial pass right at boot so a fresh deployment has content within a
|
||||
// minute instead of waiting for the first cron tick (up to 35 min). The
|
||||
// `running` guard in runPipelinePass makes overlap with the first tick safe.
|
||||
void runPipelinePass('boot');
|
||||
return 'started';
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Canada-maple brand accent
|
||||
maple: {
|
||||
50: '#fff1f0',
|
||||
100: '#ffe0dd',
|
||||
200: '#ffc3bf',
|
||||
300: '#ff9d96',
|
||||
400: '#ff6b61',
|
||||
500: '#f8402f',
|
||||
600: '#e61e2d',
|
||||
700: '#bd151f',
|
||||
800: '#9e1520',
|
||||
900: '#7f1722',
|
||||
950: '#45060c',
|
||||
},
|
||||
// Near-black ink with a warm cast
|
||||
ink: {
|
||||
50: '#f4f4f3',
|
||||
100: '#e6e6e3',
|
||||
200: '#cfcfc9',
|
||||
300: '#adada6',
|
||||
400: '#85857d',
|
||||
500: '#64645c',
|
||||
600: '#4f4f48',
|
||||
700: '#40403b',
|
||||
800: '#353531',
|
||||
900: '#2b2b28',
|
||||
950: '#161614',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"Source Serif 4"', 'Georgia', 'Cambria', 'Times New Roman', 'serif'],
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
|
||||
},
|
||||
backgroundImage: {
|
||||
'masthead-gradient':
|
||||
'linear-gradient(180deg, #161614 0%, #2b2b28 55%, #45060c 160%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||