From f1ceb36043bf7c975f223d542ef9ba5d61ca3895 Mon Sep 17 00:00:00 2001 From: Sichaow Date: Mon, 30 Mar 2026 17:24:10 -0700 Subject: [PATCH] feat: add digest workflow in github action --- .github/workflows/digest.yml | 245 +++++++++++++++++++++++ README.md | 37 ++++ README.zh-CN.md | 37 ++++ scripts/remix-digest.js | 377 +++++++++++++++++++++++++++++++++++ 4 files changed, 696 insertions(+) create mode 100644 .github/workflows/digest.yml create mode 100755 scripts/remix-digest.js diff --git a/.github/workflows/digest.yml b/.github/workflows/digest.yml new file mode 100644 index 000000000..755264ba7 --- /dev/null +++ b/.github/workflows/digest.yml @@ -0,0 +1,245 @@ +# ============================================================================= +# Follow Builders — Daily Digest Workflow +# ============================================================================= +# Orchestrates the full digest pipeline: prepare → LLM remix → deliver. +# Runs daily after feed generation, or on-demand via workflow_dispatch. +# +# ── Required GitHub Secrets ────────────────────────────────────────────────── +# +# LLM API (at least one required): +# ANTHROPIC_API_KEY — Anthropic Claude API key (priority) +# OPENAI_API_KEY — OpenAI API key (fallback) +# +# Telegram delivery (when DELIVERY_METHOD = telegram): +# TELEGRAM_BOT_TOKEN — Bot token from @BotFather +# TELEGRAM_CHAT_ID — Target chat/group ID +# +# Email delivery (when DELIVERY_METHOD = email): +# RESEND_API_KEY — Resend.com API key +# DIGEST_EMAIL — Destination email address +# +# ── Required GitHub Variables ──────────────────────────────────────────────── +# +# DIGEST_FEATURE_FLAG — Set to 'true' to enable this workflow. +# Not set in upstream repo (workflow never runs). +# +# ── Optional GitHub Variables ──────────────────────────────────────────────── +# +# DIGEST_LANGUAGE — 'en' (default), 'zh', or 'bilingual' +# DELIVERY_METHOD — 'telegram' (default), 'email', or 'stdout' +# LLM_MODEL — Override default LLM model name +# +# ============================================================================= + +name: Daily Digest + +on: + schedule: + # Run daily at 7:00 UTC — one hour after feed generation (6:00 UTC) + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + language: + description: 'Digest language' + required: false + default: 'en' + type: choice + options: + - en + - zh + - bilingual + delivery_method: + description: 'Delivery method' + required: false + default: 'telegram' + type: choice + options: + - telegram + - email + - stdout + llm_model: + description: 'LLM model override (optional)' + required: false + default: '' + type: string + +jobs: + digest: + # Feature flag — skips entire job when not 'true'. + # Upstream repo leaves this unset so the workflow never executes there. + if: vars.DIGEST_FEATURE_FLAG == 'true' + runs-on: ubuntu-latest + + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + DIGEST_EMAIL: ${{ secrets.DIGEST_EMAIL }} + DIGEST_LANGUAGE: ${{ inputs.language || vars.DIGEST_LANGUAGE || 'en' }} + DELIVERY_METHOD: ${{ inputs.delivery_method || vars.DELIVERY_METHOD || 'telegram' }} + LLM_MODEL: ${{ inputs.llm_model || vars.LLM_MODEL || '' }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: cd scripts && npm install + + # ── Task 3.2: Validate secrets ──────────────────────────────────────── + - name: Validate secrets + run: | + MISSING="" + + # At least one LLM API key is required + if [ -z "$ANTHROPIC_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then + MISSING="${MISSING} - ANTHROPIC_API_KEY or OPENAI_API_KEY (at least one LLM API key required)\n" + fi + + # Delivery-method-specific secrets + if [ "$DELIVERY_METHOD" = "telegram" ]; then + if [ -z "$TELEGRAM_BOT_TOKEN" ]; then + MISSING="${MISSING} - TELEGRAM_BOT_TOKEN (required for Telegram delivery)\n" + fi + if [ -z "$TELEGRAM_CHAT_ID" ]; then + MISSING="${MISSING} - TELEGRAM_CHAT_ID (required for Telegram delivery)\n" + fi + elif [ "$DELIVERY_METHOD" = "email" ]; then + if [ -z "$RESEND_API_KEY" ]; then + MISSING="${MISSING} - RESEND_API_KEY (required for Email delivery)\n" + fi + if [ -z "$DIGEST_EMAIL" ]; then + MISSING="${MISSING} - DIGEST_EMAIL (required for Email delivery)\n" + fi + fi + # stdout needs no delivery secrets + + if [ -n "$MISSING" ]; then + echo "::error::Missing required secrets:" + printf "$MISSING" | while IFS= read -r line; do echo "::error::$line"; done + echo "" + echo "❌ Missing required secrets:" + printf "$MISSING" + echo "" + echo "Please configure the missing secrets in your repository settings." + exit 1 + fi + + echo "✅ All required secrets are configured." + + # ── Task 3.3: Prepare → check-content → remix ──────────────────────── + - name: Prepare digest data + id: prepare + run: cd scripts && node prepare-digest.js > /tmp/prepare-output.json + + - name: Check content availability + id: check-content + run: | + HAS_CONTENT=$(node -e " + const data = JSON.parse(require('fs').readFileSync('/tmp/prepare-output.json', 'utf-8')); + const s = data.stats || {}; + const has = (s.podcastEpisodes || 0) > 0 || (s.xBuilders || 0) > 0 || (s.blogPosts || 0) > 0; + console.log(has ? 'true' : 'false'); + ") + echo "has_content=$HAS_CONTENT" >> "$GITHUB_OUTPUT" + if [ "$HAS_CONTENT" = "false" ]; then + echo "ℹ️ No new content found in feeds. Skipping remix and delivery." + else + echo "✅ Content found — proceeding with remix." + fi + + - name: Remix digest with LLM + id: remix + if: steps.check-content.outputs.has_content == 'true' + run: | + MODEL_FLAG="" + if [ -n "$LLM_MODEL" ]; then + MODEL_FLAG="--model $LLM_MODEL" + fi + cd scripts && node remix-digest.js \ + --file /tmp/prepare-output.json \ + --language "$DIGEST_LANGUAGE" \ + $MODEL_FLAG \ + > /tmp/digest-output.txt + + # ── Task 3.4: Deliver with artifact fallback ───────────────────────── + - name: Setup delivery config + id: setup-deliver-config + if: steps.check-content.outputs.has_content == 'true' + run: | + mkdir -p ~/.follow-builders + + # Build config.json and .env using node for clean JSON output + node -e " + const fs = require('fs'); + const method = process.env.DELIVERY_METHOD; + const lang = process.env.DIGEST_LANGUAGE; + const config = { language: lang, delivery: { method } }; + + if (method === 'telegram') { + config.delivery.chatId = process.env.TELEGRAM_CHAT_ID; + } else if (method === 'email') { + config.delivery.email = process.env.DIGEST_EMAIL; + } + + const dir = require('os').homedir() + '/.follow-builders'; + fs.writeFileSync(dir + '/config.json', JSON.stringify(config, null, 2)); + + let envContent = ''; + if (method === 'telegram') { + envContent = 'TELEGRAM_BOT_TOKEN=' + process.env.TELEGRAM_BOT_TOKEN; + } else if (method === 'email') { + envContent = 'RESEND_API_KEY=' + process.env.RESEND_API_KEY; + } + fs.writeFileSync(dir + '/.env', envContent); + " + + - name: Deliver digest + id: deliver + if: steps.check-content.outputs.has_content == 'true' + run: cd scripts && node deliver.js --file /tmp/digest-output.txt + + - name: Upload digest artifact (fallback) + if: failure() && steps.remix.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: digest-output + path: /tmp/digest-output.txt + retention-days: 7 + + - name: Output run summary + if: always() && steps.check-content.outputs.has_content == 'true' + run: | + # Determine LLM provider used + if [ -n "$ANTHROPIC_API_KEY" ]; then + LLM_PROVIDER="Anthropic" + elif [ -n "$OPENAI_API_KEY" ]; then + LLM_PROVIDER="OpenAI" + else + LLM_PROVIDER="N/A" + fi + + # Parse stats and write summary + node -e " + const fs = require('fs'); + const data = JSON.parse(fs.readFileSync('/tmp/prepare-output.json', 'utf-8')); + const s = data.stats || {}; + const summary = [ + '## 📰 AI Builders Digest — Run Summary', + '', + '| Metric | Count |', + '|--------|-------|', + '| 🐦 Tweets | ' + (s.totalTweets || 0) + ' |', + '| 🎙️ Podcasts | ' + (s.podcastEpisodes || 0) + ' |', + '| 📝 Blog posts | ' + (s.blogPosts || 0) + ' |', + '| 🤖 LLM Provider | ${LLM_PROVIDER} |', + '| 🌐 Language | ${DIGEST_LANGUAGE} |', + '| 📬 Delivery | ${DELIVERY_METHOD} |', + ].join('\n'); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n'); + " diff --git a/README.md b/README.md index 85e308bc3..832db236b 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,43 @@ git clone https://github.com/zarazhangrui/follow-builders.git ~/.claude/skills/f cd ~/.claude/skills/follow-builders/scripts && npm install ``` +## Deploy via GitHub Actions (No Computer Needed) + +If you don't want to keep a local agent running, you can use GitHub Actions to automatically generate and deliver your digest in the cloud. + +### Setup + +1. **Fork** this repository +2. Go to **Settings → Secrets and variables → Actions** +3. Under the **Variables** tab, create `DIGEST_FEATURE_FLAG` and set it to `true` +4. Under the **Secrets** tab, add at least one LLM API key and your delivery secrets (see table below) +5. Go to the **Actions** tab and enable workflows for your fork +6. (Optional) Trigger manually via **Actions → Daily Digest → Run workflow** to test + +### Secrets & Variables Reference + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `DIGEST_FEATURE_FLAG` | Variable | Yes | Set to `true` to enable the digest workflow | +| `ANTHROPIC_API_KEY` | Secret | At least one | Anthropic Claude API key (used by default if both are set) | +| `OPENAI_API_KEY` | Secret | At least one | OpenAI API key (fallback if Anthropic key is not set) | +| `TELEGRAM_BOT_TOKEN` | Secret | For Telegram | Bot token from @BotFather | +| `TELEGRAM_CHAT_ID` | Secret | For Telegram | Target chat or group ID | +| `RESEND_API_KEY` | Secret | For Email | Resend.com API key | +| `DIGEST_EMAIL` | Secret | For Email | Destination email address | +| `DIGEST_LANGUAGE` | Variable | No | `en` (default), `zh`, or `bilingual` | +| `DELIVERY_METHOD` | Variable | No | `telegram` (default), `email`, or `stdout` | +| `LLM_MODEL` | Variable | No | Override the default LLM model name | + +### Deployment Methods Comparison + +| | Local Agent (Kiro / Claude Code) | OpenClaw | GitHub Actions | +|---|---|---|---| +| Quality | Best (full LLM remix) | Best (persistent agent) | Good (API-based remix) | +| Always-on | No — requires computer to be on | Yes — automatic delivery | Yes — runs in cloud | +| Setup | Install skill, configure via chat | Install agent, configure | Fork repo, add secrets | +| Best for | Hands-on users who want full control | Always-on, set-and-forget | Easy cloud setup, no computer needed | + ## Requirements - An AI agent (OpenClaw, Claude Code, or similar) diff --git a/README.zh-CN.md b/README.zh-CN.md index a4206f00a..2c60bd482 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,6 +92,43 @@ git clone https://github.com/zarazhangrui/follow-builders.git ~/.claude/skills/f cd ~/.claude/skills/follow-builders/scripts && npm install ``` +## 通过 GitHub Actions 部署(无需本地电脑) + +如果你不想一直开着本地 agent,可以使用 GitHub Actions 在云端自动生成并推送摘要。 + +### 设置步骤 + +1. **Fork** 本仓库 +2. 进入 **Settings → Secrets and variables → Actions** +3. 在 **Variables** 标签页中,创建 `DIGEST_FEATURE_FLAG` 并设置为 `true` +4. 在 **Secrets** 标签页中,添加至少一个 LLM API key 和你的推送凭证(见下表) +5. 进入 **Actions** 标签页,为你的 fork 启用工作流 +6. (可选)通过 **Actions → Daily Digest → Run workflow** 手动触发测试 + +### Secrets 与 Variables 参考 + +| 名称 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `DIGEST_FEATURE_FLAG` | Variable | 是 | 设为 `true` 以启用摘要工作流 | +| `ANTHROPIC_API_KEY` | Secret | 至少一个 | Anthropic Claude API key(两者都设置时优先使用) | +| `OPENAI_API_KEY` | Secret | 至少一个 | OpenAI API key(Anthropic key 未设置时使用) | +| `TELEGRAM_BOT_TOKEN` | Secret | Telegram 推送时 | 从 @BotFather 获取的 Bot token | +| `TELEGRAM_CHAT_ID` | Secret | Telegram 推送时 | 目标聊天或群组 ID | +| `RESEND_API_KEY` | Secret | 邮件推送时 | Resend.com API key | +| `DIGEST_EMAIL` | Secret | 邮件推送时 | 目标邮箱地址 | +| `DIGEST_LANGUAGE` | Variable | 否 | `en`(默认)、`zh` 或 `bilingual` | +| `DELIVERY_METHOD` | Variable | 否 | `telegram`(默认)、`email` 或 `stdout` | +| `LLM_MODEL` | Variable | 否 | 覆盖默认 LLM 模型名称 | + +### 部署方式对比 + +| | 本地 Agent(Kiro / Claude Code) | OpenClaw | GitHub Actions | +|---|---|---|---| +| 质量 | 最佳(完整 LLM 混编) | 最佳(持久化 agent) | 良好(基于 API 混编) | +| 持续运行 | 否——需要电脑保持开机 | 是——自动推送 | 是——在云端运行 | +| 设置 | 安装 skill,通过对话配置 | 安装 agent,配置 | Fork 仓库,添加 secrets | +| 适合 | 喜欢完全掌控的动手型用户 | 一劳永逸的自动化设置 | 简单云端部署,无需电脑 | + ## 系统要求 - 一个 AI agent(OpenClaw、Claude Code 或类似工具) diff --git a/scripts/remix-digest.js b/scripts/remix-digest.js new file mode 100755 index 000000000..ccb2bb462 --- /dev/null +++ b/scripts/remix-digest.js @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +// ============================================================================ +// Follow Builders — Remix Digest +// ============================================================================ +// Reads the JSON output from prepare-digest.js, calls an LLM API +// (Anthropic Claude or OpenAI) to remix raw feed data into a readable +// digest, and outputs the final text to stdout. +// +// Usage: +// node prepare-digest.js | node remix-digest.js +// node remix-digest.js --file prepared.json +// node remix-digest.js --file prepared.json --language zh --model gpt-4o +// +// Environment variables: +// ANTHROPIC_API_KEY — use Anthropic Claude (priority) +// OPENAI_API_KEY — use OpenAI (fallback) +// +// Exit codes: +// 0 — success (or partial success with warnings on stderr) +// 1 — fatal error (no API key, bad input, etc.) +// ============================================================================ + +import { readFile } from 'fs/promises'; + +// -- Constants --------------------------------------------------------------- + +const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; +const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'; +const ANTHROPIC_VERSION = '2023-06-01'; +const DEFAULT_MAX_TOKENS = 4096; +const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-20250514'; +const DEFAULT_OPENAI_MODEL = 'gpt-4o'; + +const REQUIRED_PROMPT_KEYS = [ + 'summarize_tweets', + 'summarize_podcast', + 'summarize_blogs', + 'digest_intro', + 'translate', +]; + +// -- CLI Argument Parsing ---------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + const parsed = { file: null, language: 'en', model: null }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--file': + parsed.file = args[++i]; + break; + case '--language': + parsed.language = args[++i] || 'en'; + break; + case '--model': + parsed.model = args[++i] || null; + break; + } + } + return parsed; +} + +// -- Input Reading ----------------------------------------------------------- + +async function readInput(filePath) { + if (filePath) { + try { + return await readFile(filePath, 'utf-8'); + } catch (err) { + process.stderr.write(JSON.stringify({ + status: 'error', + step: 'read-input', + message: `Cannot read file: ${filePath}`, + details: { error: err.message }, + }) + '\n'); + process.exit(1); + } + } + + // Read from stdin + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +function parseAndValidateInput(raw) { + let data; + try { + data = JSON.parse(raw); + } catch (err) { + process.stderr.write(JSON.stringify({ + status: 'error', + step: 'parse-input', + message: 'Invalid input JSON', + details: { error: err.message }, + }) + '\n'); + process.exit(1); + } + + // Validate required prompts field + if (!data.prompts || typeof data.prompts !== 'object') { + process.stderr.write(JSON.stringify({ + status: 'error', + step: 'validate-input', + message: 'Missing required field: prompts', + }) + '\n'); + process.exit(1); + } + + const missing = REQUIRED_PROMPT_KEYS.filter((k) => !data.prompts[k]); + if (missing.length > 0) { + process.stderr.write(JSON.stringify({ + status: 'error', + step: 'validate-input', + message: `Missing required prompt keys: ${missing.join(', ')}`, + }) + '\n'); + process.exit(1); + } + + return data; +} + +// -- LLM Provider Abstraction ------------------------------------------------ + +function detectProvider(modelOverride) { + const anthropicKey = process.env.ANTHROPIC_API_KEY; + const openaiKey = process.env.OPENAI_API_KEY; + + if (anthropicKey) { + return { + name: 'anthropic', + apiKey: anthropicKey, + model: modelOverride || DEFAULT_ANTHROPIC_MODEL, + }; + } + + if (openaiKey) { + return { + name: 'openai', + apiKey: openaiKey, + model: modelOverride || DEFAULT_OPENAI_MODEL, + }; + } + + process.stderr.write(JSON.stringify({ + status: 'error', + step: 'detect-provider', + message: 'No LLM API key found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable.', + }) + '\n'); + process.exit(1); +} + +async function callLLM(systemPrompt, userMessage, options = {}) { + const { provider, maxTokens = DEFAULT_MAX_TOKENS } = options; + + if (provider.name === 'anthropic') { + return callAnthropic(systemPrompt, userMessage, provider, maxTokens); + } + return callOpenAI(systemPrompt, userMessage, provider, maxTokens); +} + +async function callAnthropic(systemPrompt, userMessage, provider, maxTokens) { + const res = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': provider.apiKey, + 'anthropic-version': ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: provider.model, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: 'user', content: userMessage }], + }), + }); + + if (!res.ok) { + const errBody = await res.text(); + let errMsg; + try { + errMsg = JSON.parse(errBody).error?.message || errBody; + } catch { + errMsg = errBody; + } + const error = new Error(`Anthropic API error: ${res.status} ${errMsg}`); + error.httpStatus = res.status; + error.apiMessage = errMsg; + throw error; + } + + const json = await res.json(); + const text = json.content?.[0]?.text; + if (!text) { + throw new Error('Empty response from LLM'); + } + return text; +} + +async function callOpenAI(systemPrompt, userMessage, provider, maxTokens) { + const res = await fetch(OPENAI_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${provider.apiKey}`, + }, + body: JSON.stringify({ + model: provider.model, + max_tokens: maxTokens, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }), + }); + + if (!res.ok) { + const errBody = await res.text(); + let errMsg; + try { + errMsg = JSON.parse(errBody).error?.message || errBody; + } catch { + errMsg = errBody; + } + const error = new Error(`OpenAI API error: ${res.status} ${errMsg}`); + error.httpStatus = res.status; + error.apiMessage = errMsg; + throw error; + } + + const json = await res.json(); + const text = json.choices?.[0]?.message?.content; + if (!text) { + throw new Error('Empty response from LLM'); + } + return text; +} + +// -- Multi-Step LLM Calls & Digest Assembly ---------------------------------- + +async function summarizeTweets(xBuilders, prompt, provider) { + const tasks = xBuilders.map(async (builder) => { + const userMsg = JSON.stringify({ + author: builder.author, + handle: builder.handle, + tweets: builder.tweets, + }); + try { + return await callLLM(prompt, userMsg, { provider }); + } catch (err) { + process.stderr.write(JSON.stringify({ + status: 'warning', + step: 'remix-tweets', + message: `Skipping tweets for ${builder.author}: ${err.message}`, + details: { builder: builder.handle, httpStatus: err.httpStatus }, + }) + '\n'); + return null; + } + }); + return (await Promise.all(tasks)).filter(Boolean); +} + +async function summarizePodcasts(podcasts, prompt, provider) { + const tasks = podcasts.map(async (podcast) => { + const userMsg = JSON.stringify({ + name: podcast.name, + title: podcast.title, + url: podcast.url, + transcript: podcast.transcript, + publishedAt: podcast.publishedAt, + }); + try { + return await callLLM(prompt, userMsg, { provider }); + } catch (err) { + process.stderr.write(JSON.stringify({ + status: 'warning', + step: 'remix-podcasts', + message: `Skipping podcast "${podcast.name}": ${err.message}`, + details: { podcast: podcast.name, httpStatus: err.httpStatus }, + }) + '\n'); + return null; + } + }); + return (await Promise.all(tasks)).filter(Boolean); +} + +async function summarizeBlogs(blogs, prompt, provider) { + const tasks = blogs.map(async (blog) => { + const userMsg = JSON.stringify({ + source: blog.source, + title: blog.title, + url: blog.url, + content: blog.content, + author: blog.author, + publishedAt: blog.publishedAt, + }); + try { + return await callLLM(prompt, userMsg, { provider }); + } catch (err) { + process.stderr.write(JSON.stringify({ + status: 'warning', + step: 'remix-blogs', + message: `Skipping blog "${blog.title}": ${err.message}`, + details: { blog: blog.title, httpStatus: err.httpStatus }, + }) + '\n'); + return null; + } + }); + return (await Promise.all(tasks)).filter(Boolean); +} + +async function assembleDigest(tweetSummaries, podcastSummaries, blogSummaries, prompt, provider) { + const userMsg = JSON.stringify({ + tweetSummaries, + podcastSummaries, + blogSummaries, + }); + return callLLM(prompt, userMsg, { provider }); +} + +async function translateDigest(digestText, prompt, provider, language) { + const userMsg = JSON.stringify({ + digest: digestText, + targetMode: language, // 'zh' or 'bilingual' + }); + return callLLM(prompt, userMsg, { provider }); +} + +// -- Main -------------------------------------------------------------------- + +async function main() { + const args = parseArgs(); + const provider = detectProvider(args.model); + + // Read and validate input + const raw = await readInput(args.file); + const data = parseAndValidateInput(raw); + const { prompts } = data; + + // Steps 1-3: Parallel LLM calls for tweets, podcasts, blogs + const [tweetSummaries, podcastSummaries, blogSummaries] = await Promise.all([ + summarizeTweets(data.x || [], prompts.summarize_tweets, provider), + summarizePodcasts(data.podcasts || [], prompts.summarize_podcast, provider), + summarizeBlogs(data.blogs || [], prompts.summarize_blogs, provider), + ]); + + // Step 4: Assemble full digest + let digest = await assembleDigest( + tweetSummaries, + podcastSummaries, + blogSummaries, + prompts.digest_intro, + provider, + ); + + // Step 5: Translate if needed + if (args.language === 'zh' || args.language === 'bilingual') { + digest = await translateDigest(digest, prompts.translate, provider, args.language); + } + + // Output final digest to stdout + process.stdout.write(digest); +} + +main().catch((err) => { + process.stderr.write(JSON.stringify({ + status: 'error', + step: 'fatal', + message: err.message, + details: { httpStatus: err.httpStatus, apiMessage: err.apiMessage }, + }) + '\n'); + process.exit(1); +});