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)
This commit is contained in:
2026-08-15 16:07:07 -04:00
commit 1010f27f44
76 changed files with 7177 additions and 0 deletions
@@ -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");
+3
View File
@@ -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"
+79
View File
@@ -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
}
+30
View File
@@ -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);
});