Archived
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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user