Initial commit: Daily tech newsletter worker

A Node.js/TypeScript worker that sends daily AI-generated summaries
of tech news from X/Twitter via Nitter RSS feeds.

Features:
- Fetches tweets from 40+ curated tech accounts via Nitter RSS
- Filters and categorizes by topic (AI/ML, SWE, General Tech)
- Generates AI summaries using OpenRouter (Claude/GPT-4)
- Sends professional HTML email via Brevo SMTP
- Runs on cron schedule inside Docker container
- Instance rotation for Nitter reliability
- Graceful degradation if AI fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 20:23:38 +00:00
commit b3643fd5b0
23 changed files with 2091 additions and 0 deletions

60
src/config/accounts.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { TechAccount } from '../types/index.js';
export const TECH_ACCOUNTS: TechAccount[] = [
// ===========================================
// AI / Machine Learning
// ===========================================
{ username: 'karpathy', displayName: 'Andrej Karpathy', category: 'ai_ml', priority: 'high' },
{ username: 'ylecun', displayName: 'Yann LeCun', category: 'ai_ml', priority: 'high' },
{ username: 'AndrewYNg', displayName: 'Andrew Ng', category: 'ai_ml', priority: 'high' },
{ username: 'sama', displayName: 'Sam Altman', category: 'ai_ml', priority: 'high' },
{ username: 'demaboris', displayName: 'Demis Hassabis', category: 'ai_ml', priority: 'high' },
{ username: 'goodaboris', displayName: 'Ian Goodfellow', category: 'ai_ml', priority: 'medium' },
{ username: 'fchollet', displayName: 'François Chollet', category: 'ai_ml', priority: 'high' },
{ username: 'EMostaque', displayName: 'Emad Mostaque', category: 'ai_ml', priority: 'medium' },
{ username: 'JimFan', displayName: 'Jim Fan', category: 'ai_ml', priority: 'high' },
{ username: 'gaborig', displayName: 'George Hotz', category: 'ai_ml', priority: 'medium' },
{ username: 'ClaudeMcAI', displayName: 'Claude (Anthropic)', category: 'ai_ml', priority: 'medium' },
{ username: 'OpenAI', displayName: 'OpenAI', category: 'ai_ml', priority: 'high' },
{ username: 'AnthropicAI', displayName: 'Anthropic', category: 'ai_ml', priority: 'high' },
{ username: 'huggingface', displayName: 'Hugging Face', category: 'ai_ml', priority: 'high' },
// ===========================================
// Software Engineering / Dev Tools
// ===========================================
{ username: 'ThePrimeagen', displayName: 'ThePrimeagen', category: 'swe', priority: 'high' },
{ username: 'kelseyhightower', displayName: 'Kelsey Hightower', category: 'swe', priority: 'high' },
{ username: 'mitchellh', displayName: 'Mitchell Hashimoto', category: 'swe', priority: 'high' },
{ username: 'tjholowaychuk', displayName: 'TJ Holowaychuk', category: 'swe', priority: 'medium' },
{ username: 'addyosmani', displayName: 'Addy Osmani', category: 'swe', priority: 'medium' },
{ username: 'sarah_edo', displayName: 'Sarah Drasner', category: 'swe', priority: 'medium' },
{ username: 'dan_abramov', displayName: 'Dan Abramov', category: 'swe', priority: 'high' },
{ username: 'swyx', displayName: 'swyx', category: 'swe', priority: 'medium' },
{ username: 'kentcdodds', displayName: 'Kent C. Dodds', category: 'swe', priority: 'medium' },
{ username: 'taborwang', displayName: 'Tanner Linsley', category: 'swe', priority: 'medium' },
{ username: 'raaborig', displayName: 'Ryan Dahl', category: 'swe', priority: 'high' },
{ username: 'vercel', displayName: 'Vercel', category: 'swe', priority: 'medium' },
{ username: 'github', displayName: 'GitHub', category: 'swe', priority: 'medium' },
// ===========================================
// General Tech / Startups
// ===========================================
{ username: 'levelsio', displayName: 'Pieter Levels', category: 'general_tech', priority: 'high' },
{ username: 'paulg', displayName: 'Paul Graham', category: 'general_tech', priority: 'high' },
{ username: 'naval', displayName: 'Naval Ravikant', category: 'general_tech', priority: 'high' },
{ username: 'elaborig', displayName: 'Elon Musk', category: 'general_tech', priority: 'medium' },
{ username: 'jason', displayName: 'Jason Calacanis', category: 'general_tech', priority: 'medium' },
{ username: 'balajis', displayName: 'Balaji Srinivasan', category: 'general_tech', priority: 'medium' },
{ username: 'pmarca', displayName: 'Marc Andreessen', category: 'general_tech', priority: 'medium' },
{ username: 'aborig', displayName: 'DHH', category: 'general_tech', priority: 'high' },
{ username: 'benedictevans', displayName: 'Benedict Evans', category: 'general_tech', priority: 'medium' },
{ username: 'jason_f', displayName: 'Jason Fried', category: 'general_tech', priority: 'medium' },
];
export function getAccountsByCategory(category: TechAccount['category']): TechAccount[] {
return TECH_ACCOUNTS.filter((account) => account.category === category);
}
export function getHighPriorityAccounts(): TechAccount[] {
return TECH_ACCOUNTS.filter((account) => account.priority === 'high');
}

89
src/config/index.ts Normal file
View File

@@ -0,0 +1,89 @@
import { config as dotenvConfig } from 'dotenv';
import { z } from 'zod';
import type { AppConfig } from '../types/index.js';
dotenvConfig();
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
NITTER_INSTANCES: z.string().default('https://nitter.poast.org,https://xcancel.com'),
RSS_FETCH_TIMEOUT: z.coerce.number().default(30000),
RSS_MAX_TWEETS_PER_ACCOUNT: z.coerce.number().default(50),
OPENROUTER_API_KEY: z.string().min(1, 'OPENROUTER_API_KEY is required'),
OPENROUTER_MODEL: z.string().default('anthropic/claude-3-sonnet-20240229'),
OPENROUTER_MAX_TOKENS: z.coerce.number().default(2000),
OPENROUTER_SITE_URL: z.string().default('https://tech-newsletter.local'),
OPENROUTER_SITE_NAME: z.string().default('Tech Newsletter'),
BREVO_SMTP_HOST: z.string().default('smtp-relay.brevo.com'),
BREVO_SMTP_PORT: z.coerce.number().default(587),
BREVO_SMTP_USER: z.string().min(1, 'BREVO_SMTP_USER is required'),
BREVO_SMTP_KEY: z.string().min(1, 'BREVO_SMTP_KEY is required'),
EMAIL_FROM_ADDRESS: z.string().email('Invalid EMAIL_FROM_ADDRESS'),
EMAIL_FROM_NAME: z.string().default('Daily Tech Digest'),
EMAIL_RECIPIENTS: z.string().min(1, 'EMAIL_RECIPIENTS is required'),
CRON_SCHEDULE: z.string().default('0 7 * * *'),
CRON_TIMEZONE: z.string().default('Europe/Warsaw'),
ENABLE_AI_SUMMARIES: z.coerce.boolean().default(true),
INCLUDE_RETWEETS: z.coerce.boolean().default(false),
INCLUDE_REPLIES: z.coerce.boolean().default(false),
DRY_RUN: z.coerce.boolean().default(false),
});
function loadConfig(): AppConfig {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Configuration validation failed:');
for (const error of result.error.errors) {
console.error(` - ${error.path.join('.')}: ${error.message}`);
}
process.exit(1);
}
const env = result.data;
return {
rss: {
nitterInstances: env.NITTER_INSTANCES.split(',').map((s) => s.trim()),
fetchTimeout: env.RSS_FETCH_TIMEOUT,
maxTweetsPerAccount: env.RSS_MAX_TWEETS_PER_ACCOUNT,
},
ai: {
openRouterApiKey: env.OPENROUTER_API_KEY,
model: env.OPENROUTER_MODEL,
maxTokens: env.OPENROUTER_MAX_TOKENS,
siteUrl: env.OPENROUTER_SITE_URL,
siteName: env.OPENROUTER_SITE_NAME,
},
email: {
brevoHost: env.BREVO_SMTP_HOST,
brevoPort: env.BREVO_SMTP_PORT,
brevoUser: env.BREVO_SMTP_USER,
brevoApiKey: env.BREVO_SMTP_KEY,
fromEmail: env.EMAIL_FROM_ADDRESS,
fromName: env.EMAIL_FROM_NAME,
recipients: env.EMAIL_RECIPIENTS.split(',').map((s) => s.trim()),
},
scheduler: {
cronExpression: env.CRON_SCHEDULE,
timezone: env.CRON_TIMEZONE,
},
features: {
enableAiSummaries: env.ENABLE_AI_SUMMARIES,
includeRetweets: env.INCLUDE_RETWEETS,
includeReplies: env.INCLUDE_REPLIES,
dryRun: env.DRY_RUN,
},
logging: {
level: env.LOG_LEVEL,
},
};
}
export const config = loadConfig();

210
src/config/topics.ts Normal file
View File

@@ -0,0 +1,210 @@
import type { TopicConfig, TopicId } from '../types/index.js';
export const TOPICS: TopicConfig[] = [
{
id: 'ai_ml',
name: 'AI & Machine Learning',
keywords: [
'gpt',
'gpt-4',
'gpt-5',
'chatgpt',
'llm',
'large language model',
'transformer',
'neural network',
'deep learning',
'machine learning',
'ml',
'artificial intelligence',
'ai',
'openai',
'anthropic',
'claude',
'gemini',
'mistral',
'llama',
'fine-tuning',
'fine tuning',
'rag',
'retrieval augmented',
'embedding',
'vector database',
'prompt engineering',
'prompt',
'diffusion',
'stable diffusion',
'midjourney',
'dall-e',
'sora',
'multimodal',
'vision model',
'nlp',
'natural language',
'rlhf',
'reinforcement learning',
'model training',
'inference',
'hugging face',
'pytorch',
'tensorflow',
'jax',
'agi',
'superintelligence',
'ai safety',
'alignment',
],
icon: '🤖',
},
{
id: 'swe',
name: 'Software Engineering',
keywords: [
'programming',
'coding',
'software',
'developer',
'development',
'typescript',
'javascript',
'python',
'rust',
'go',
'golang',
'java',
'c++',
'react',
'vue',
'angular',
'svelte',
'node',
'nodejs',
'deno',
'bun',
'next.js',
'nextjs',
'remix',
'api',
'rest',
'graphql',
'grpc',
'microservices',
'kubernetes',
'k8s',
'docker',
'container',
'devops',
'ci/cd',
'git',
'github',
'gitlab',
'open source',
'oss',
'framework',
'library',
'package',
'npm',
'testing',
'tdd',
'clean code',
'architecture',
'design pattern',
'refactoring',
'database',
'postgresql',
'mysql',
'mongodb',
'redis',
'aws',
'azure',
'gcp',
'cloud',
'serverless',
'edge',
'wasm',
'webassembly',
'vim',
'neovim',
'vscode',
'ide',
'terminal',
'cli',
],
icon: '💻',
},
{
id: 'general_tech',
name: 'Tech & Startups',
keywords: [
'startup',
'founder',
'entrepreneur',
'indie hacker',
'indiehacker',
'saas',
'product',
'launch',
'shipped',
'mvp',
'funding',
'vc',
'venture capital',
'seed',
'series a',
'ipo',
'acquisition',
'tech news',
'silicon valley',
'y combinator',
'yc',
'product hunt',
'hacker news',
'tech twitter',
'remote work',
'wfh',
'crypto',
'blockchain',
'web3',
'bitcoin',
'ethereum',
'nft',
'defi',
'fintech',
'biotech',
'climate tech',
'hardware',
'robotics',
'automation',
'productivity',
'notion',
'obsidian',
'tech layoffs',
'hiring',
'tech jobs',
'career',
'salary',
'compensation',
],
icon: '🚀',
},
];
export function getTopicById(id: TopicId): TopicConfig | undefined {
return TOPICS.find((topic) => topic.id === id);
}
export function matchTopics(text: string): TopicId[] {
const lowerText = text.toLowerCase();
const matched: TopicId[] = [];
for (const topic of TOPICS) {
for (const keyword of topic.keywords) {
if (lowerText.includes(keyword.toLowerCase())) {
matched.push(topic.id);
break;
}
}
}
return matched;
}