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:
60
src/config/accounts.ts
Normal file
60
src/config/accounts.ts
Normal 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
89
src/config/index.ts
Normal 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
210
src/config/topics.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user