diff --git a/.github/workflows/daily-digest.yml b/.github/workflows/daily-digest.yml index 84ee464..7d42ce0 100644 --- a/.github/workflows/daily-digest.yml +++ b/.github/workflows/daily-digest.yml @@ -67,10 +67,12 @@ jobs: git push fi - - name: Send Telegram notification + - name: Send notifications env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID || '@agents_radar' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} PAGES_URL: https://duanyytop.github.io/agents-radar run: pnpm notify diff --git a/index.html b/index.html index f05af95..4f0317f 100644 --- a/index.html +++ b/index.html @@ -288,6 +288,8 @@ 'ai-cli-en': 'AI CLI Tools', 'ai-agents': 'AI Agents 生态', 'ai-agents-en': 'AI Agents Ecosystem', + 'ai-mcp': 'MCP 生态', + 'ai-mcp-en': 'MCP Ecosystem', 'ai-web': 'OpenAI & Anthropic 官网动态', 'ai-web-en': 'OpenAI & Anthropic Updates', 'ai-trending': 'GitHub AI 趋势', diff --git a/src/config.ts b/src/config.ts index 4e798ac..f6b6469 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ interface RawRepoEntry { interface RawConfig { cli_repos?: RawRepoEntry[]; + mcp_repos?: RawRepoEntry[]; skills_repo?: string; openclaw?: RawRepoEntry; openclaw_peers?: RawRepoEntry[]; @@ -28,6 +29,7 @@ interface RawConfig { export interface RadarConfig { cliRepos: RepoConfig[]; + mcpRepos: RepoConfig[]; skillsRepo: string; openclaw: RepoConfig; openclawPeers: RepoConfig[]; @@ -56,6 +58,15 @@ const DEFAULT_OPENCLAW: RepoConfig = { paginated: true, }; +const DEFAULT_MCP_REPOS: RepoConfig[] = [ + { id: "mcp-spec", repo: "modelcontextprotocol/specification", name: "MCP Specification" }, + { id: "mcp-ts-sdk", repo: "modelcontextprotocol/typescript-sdk", name: "MCP TypeScript SDK" }, + { id: "mcp-py-sdk", repo: "modelcontextprotocol/python-sdk", name: "MCP Python SDK" }, + { id: "mcp-servers", repo: "modelcontextprotocol/servers", name: "MCP Official Servers" }, + { id: "github-mcp", repo: "github/github-mcp-server", name: "GitHub MCP Server" }, + { id: "context7", repo: "upstash/context7", name: "Context7" }, +]; + const DEFAULT_OPENCLAW_PEERS: RepoConfig[] = [ { id: "nanobot", repo: "HKUDS/nanobot", name: "NanoBot", paginated: true }, { id: "zeroclaw", repo: "zeroclaw-labs/zeroclaw", name: "Zeroclaw" }, @@ -84,6 +95,7 @@ export function loadConfig(configPath = "config.yml"): RadarConfig { console.log(`[config] ${configPath} not found — using built-in defaults.`); return { cliRepos: DEFAULT_CLI_REPOS, + mcpRepos: DEFAULT_MCP_REPOS, skillsRepo: DEFAULT_SKILLS_REPO, openclaw: DEFAULT_OPENCLAW, openclawPeers: DEFAULT_OPENCLAW_PEERS, @@ -104,6 +116,11 @@ export function loadConfig(configPath = "config.yml"): RadarConfig { const openclaw = raw?.openclaw?.id && raw.openclaw.repo ? toRepoConfig(raw.openclaw) : DEFAULT_OPENCLAW; + const mcpRepos = + Array.isArray(raw?.mcp_repos) && raw.mcp_repos.length > 0 + ? raw.mcp_repos.map(toRepoConfig) + : DEFAULT_MCP_REPOS; + const openclawPeers = Array.isArray(raw?.openclaw_peers) && raw.openclaw_peers.length > 0 ? raw.openclaw_peers.map(toRepoConfig) @@ -111,8 +128,8 @@ export function loadConfig(configPath = "config.yml"): RadarConfig { console.log( `[config] Loaded from ${configPath}: ` + - `${cliRepos.length} CLI repos, ${openclawPeers.length} OpenClaw peers`, + `${cliRepos.length} CLI repos, ${mcpRepos.length} MCP repos, ${openclawPeers.length} OpenClaw peers`, ); - return { cliRepos, skillsRepo, openclaw, openclawPeers }; + return { cliRepos, mcpRepos, skillsRepo, openclaw, openclawPeers }; } diff --git a/src/generate-manifest.ts b/src/generate-manifest.ts index c337222..0692c3f 100644 --- a/src/generate-manifest.ts +++ b/src/generate-manifest.ts @@ -11,6 +11,8 @@ const REPORT_FILES = [ "ai-cli-en", "ai-agents", "ai-agents-en", + "ai-mcp", + "ai-mcp-en", "ai-web", "ai-web-en", "ai-trending", @@ -29,6 +31,8 @@ const REPORT_LABELS: Record = { "ai-cli-en": "AI CLI Tools Digest", "ai-agents": "AI Agents 生态日报", "ai-agents-en": "AI Agents Ecosystem Digest", + "ai-mcp": "MCP 生态日报", + "ai-mcp-en": "MCP Ecosystem Digest", "ai-web": "AI 官方内容追踪报告", "ai-web-en": "Official AI Content Report", "ai-trending": "AI 开源趋势日报", diff --git a/src/github.ts b/src/github.ts index 35c4bde..7d14a81 100644 --- a/src/github.ts +++ b/src/github.ts @@ -191,6 +191,8 @@ export async function createGitHubIssue(title: string, body: string, label: stri } const LABEL_COLORS: Record = { openclaw: "e11d48", + mcp: "8b5cf6", + "mcp-en": "a78bfa", trending: "f9a825", hn: "ff6600", weekly: "7c3aed", diff --git a/src/index.ts b/src/index.ts index 3923503..0a000ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,13 +24,18 @@ import { buildPeerPrompt, buildComparisonPrompt, buildPeersComparisonPrompt, + buildMcpComparisonPrompt, buildSkillsPrompt, buildWebReportPrompt, buildTrendingPrompt, buildHnPrompt, } from "./prompts.ts"; import { callLlm, saveFile, autoGenFooter } from "./report.ts"; -import { buildCliReportContent, buildOpenclawReportContent } from "./report-builders.ts"; +import { + buildCliReportContent, + buildOpenclawReportContent, + buildMcpReportContent, +} from "./report-builders.ts"; import { loadWebState, saveWebState, fetchSiteContent, type WebFetchResult, type WebState } from "./web.ts"; import { fetchTrendingData, type TrendingData } from "./trending.ts"; import { fetchHnData, type HnData } from "./hn.ts"; @@ -42,6 +47,7 @@ import { loadConfig } from "./config.ts"; const { cliRepos: CLI_REPOS, + mcpRepos: MCP_REPOS, skillsRepo: CLAUDE_SKILLS_REPO, openclaw: OPENCLAW, openclawPeers: OPENCLAW_PEERS, @@ -82,7 +88,7 @@ async function fetchAllData( trendingData: TrendingData; hnData: HnData; }> { - const allConfigs = [...CLI_REPOS, OPENCLAW, ...OPENCLAW_PEERS]; + const allConfigs = [...CLI_REPOS, ...MCP_REPOS, OPENCLAW, ...OPENCLAW_PEERS]; console.log(` Tracking: ${allConfigs.map((r) => r.id).join(", ")}, claude-code-skills, web, hn`); const [fetched, skillsData, webResults, trendingData, hnData] = await Promise.all([ @@ -420,7 +426,11 @@ async function main(): Promise { const { fetched, skillsData, webResults, trendingData, hnData } = await fetchAllData(since, webState); const peerIds = new Set(OPENCLAW_PEERS.map((p) => p.id)); - const fetchedCli = fetched.filter((f) => f.cfg.id !== OPENCLAW.id && !peerIds.has(f.cfg.id)); + const mcpIds = new Set(MCP_REPOS.map((r) => r.id)); + const fetchedCli = fetched.filter( + (f) => f.cfg.id !== OPENCLAW.id && !peerIds.has(f.cfg.id) && !mcpIds.has(f.cfg.id), + ); + const fetchedMcp = fetched.filter((f) => mcpIds.has(f.cfg.id)); const fetchedOpenclaw = fetched.find((f) => f.cfg.id === OPENCLAW.id)!; const fetchedPeers = fetched.filter((f) => peerIds.has(f.cfg.id)); @@ -431,6 +441,36 @@ async function main(): Promise { generateSummaries(fetchedCli, fetchedOpenclaw, skillsData, fetchedPeers, trendingData, dateStr, "en"), ]); + // 2b. Generate MCP per-repo summaries (zh + en) + console.log(" Generating MCP summaries in ZH and EN..."); + const generateMcpDigests = async (lang: "zh" | "en"): Promise => { + const noActivity = lang === "en" ? "No activity in the last 24 hours." : "过去24小时无活动。"; + const summaryFailed = lang === "en" ? "⚠️ Summary generation failed." : "⚠️ 摘要生成失败。"; + return Promise.all( + fetchedMcp.map(async ({ cfg, issues, prs, releases }): Promise => { + const hasData = issues.length || prs.length || releases.length; + if (!hasData) { + console.log(` [${cfg.id}] No activity, skipping LLM call`); + return { config: cfg, issues, prs, releases, summary: noActivity }; + } + console.log(` [${cfg.id}] Calling LLM for MCP summary (${lang})...`); + try { + const summary = await callLlm( + buildPeerPrompt(cfg, issues, prs, releases, dateStr, undefined, undefined, lang), + ); + return { config: cfg, issues, prs, releases, summary }; + } catch (err) { + console.error(` [${cfg.id}] LLM call failed: ${err}`); + return { config: cfg, issues, prs, releases, summary: summaryFailed }; + } + }), + ); + }; + const [zhMcpDigests, enMcpDigests] = await Promise.all([ + generateMcpDigests("zh"), + generateMcpDigests("en"), + ]); + // 3. Generate cross-repo comparisons in parallel (zh + en) console.log(" Calling LLM for comparative analyses (ZH + EN)..."); const openclawDigest: RepoDigest = { @@ -447,12 +487,15 @@ async function main(): Promise { releases: fetchedOpenclaw.releases, summary: enSummaries.openclawSummary, }; - const [comparison, peersComparison, enComparison, enPeersComparison] = await Promise.all([ - callLlm(buildComparisonPrompt(zhSummaries.cliDigests, dateStr, "zh")), - callLlm(buildPeersComparisonPrompt(openclawDigest, zhSummaries.peerDigests, dateStr, "zh")), - callLlm(buildComparisonPrompt(enSummaries.cliDigests, dateStr, "en")), - callLlm(buildPeersComparisonPrompt(enOpenclawDigest, enSummaries.peerDigests, dateStr, "en")), - ]); + const [comparison, peersComparison, enComparison, enPeersComparison, mcpComparison, enMcpComparison] = + await Promise.all([ + callLlm(buildComparisonPrompt(zhSummaries.cliDigests, dateStr, "zh")), + callLlm(buildPeersComparisonPrompt(openclawDigest, zhSummaries.peerDigests, dateStr, "zh")), + callLlm(buildComparisonPrompt(enSummaries.cliDigests, dateStr, "en")), + callLlm(buildPeersComparisonPrompt(enOpenclawDigest, enSummaries.peerDigests, dateStr, "en")), + callLlm(buildMcpComparisonPrompt(zhMcpDigests, dateStr, "zh")), + callLlm(buildMcpComparisonPrompt(enMcpDigests, dateStr, "en")), + ]); const footer = autoGenFooter("zh"); const enFooter = autoGenFooter("en"); @@ -503,10 +546,15 @@ async function main(): Promise { "en", ); + const mcpContent = buildMcpReportContent(zhMcpDigests, mcpComparison, utcStr, dateStr, footer, "zh"); + const enMcpContent = buildMcpReportContent(enMcpDigests, enMcpComparison, utcStr, dateStr, enFooter, "en"); + console.log(` Saved ${saveFile(digestContent, dateStr, "ai-cli.md")}`); console.log(` Saved ${saveFile(openclawContent, dateStr, "ai-agents.md")}`); + console.log(` Saved ${saveFile(mcpContent, dateStr, "ai-mcp.md")}`); console.log(` Saved ${saveFile(enDigestContent, dateStr, "ai-cli-en.md")}`); console.log(` Saved ${saveFile(enOpenclawContent, dateStr, "ai-agents-en.md")}`); + console.log(` Saved ${saveFile(enMcpContent, dateStr, "ai-mcp-en.md")}`); // Web report: zh saves state, en skips state save await saveWebReport(webResults, webState, utcStr, dateStr, digestRepo, footer, "zh"); @@ -552,6 +600,12 @@ async function main(): Promise { "openclaw-en", ); console.log(` Created OpenClaw issue (en): ${openclawEnUrl}`); + + const mcpUrl = await createGitHubIssue(`🔌 MCP 生态日报 ${dateStr}`, mcpContent, "mcp"); + console.log(` Created MCP issue (zh): ${mcpUrl}`); + + const mcpEnUrl = await createGitHubIssue(`🔌 MCP Ecosystem Digest ${dateStr}`, enMcpContent, "mcp-en"); + console.log(` Created MCP issue (en): ${mcpEnUrl}`); } console.log("Done!"); diff --git a/src/notify.ts b/src/notify.ts index 398ab62..c97cc15 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -1,12 +1,13 @@ /** - * Telegram notification — reads manifest.json and sends a message - * with links to the latest reports. Skips silently if secrets are not set. + * Multi-channel notification dispatcher — reads manifest.json and sends a message + * with links to the latest reports. Each channel is opt-in via env vars. * - * Required env vars: - * TELEGRAM_BOT_TOKEN — bot token from @BotFather - * TELEGRAM_CHAT_ID — channel/group/user chat ID + * Supported channels: + * Telegram — TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID + * Slack — SLACK_WEBHOOK_URL + * Discord — DISCORD_WEBHOOK_URL * Optional: - * PAGES_URL — GitHub Pages base URL (defaults to the public deployment) + * PAGES_URL — GitHub Pages base URL (defaults to the public deployment) */ import fs from "node:fs"; @@ -16,6 +17,7 @@ const PAGES_URL_DEFAULT = "https://duanyytop.github.io/agents-radar"; const ZH_LABELS: Record = { "ai-cli": "AI CLI 工具", "ai-agents": "AI Agents 生态", + "ai-mcp": "MCP 生态", "ai-web": "官网动态", "ai-trending": "GitHub 趋势", "ai-hn": "HN 社区动态", @@ -26,6 +28,7 @@ const ZH_LABELS: Record = { const EN_LABELS: Record = { "ai-cli": "AI CLI Tools", "ai-agents": "AI Agents Ecosystem", + "ai-mcp": "MCP Ecosystem", "ai-web": "Official Updates", "ai-trending": "GitHub Trends", "ai-hn": "HN Community", @@ -33,6 +36,21 @@ const EN_LABELS: Record = { "ai-monthly": "AI Tools Monthly", }; +const REPORT_EMOJIS: Record = { + "ai-cli": "🔧", + "ai-agents": "🦞", + "ai-mcp": "🔌", + "ai-web": "🌐", + "ai-trending": "📈", + "ai-hn": "📰", + "ai-weekly": "📅", + "ai-monthly": "📆", +}; + +// --------------------------------------------------------------------------- +// Telegram +// --------------------------------------------------------------------------- + async function sendTelegram(text: string): Promise { const BOT_TOKEN = process.env["TELEGRAM_BOT_TOKEN"] ?? ""; const CHAT_ID = process.env["TELEGRAM_CHAT_ID"] || "@agents_radar"; @@ -53,6 +71,57 @@ async function sendTelegram(text: string): Promise { } } +// --------------------------------------------------------------------------- +// Slack +// --------------------------------------------------------------------------- + +async function sendSlack(webhookUrl: string, text: string): Promise { + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Slack webhook ${res.status}: ${body}`); + } +} + +// --------------------------------------------------------------------------- +// Discord +// --------------------------------------------------------------------------- + +async function sendDiscord( + webhookUrl: string, + title: string, + description: string, + url: string, +): Promise { + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + embeds: [ + { + title, + description, + url, + color: 0xe8a03d, + footer: { text: "agents-radar" }, + }, + ], + }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Discord webhook ${res.status}: ${body}`); + } +} + +// --------------------------------------------------------------------------- +// Message builders +// --------------------------------------------------------------------------- + export function buildMessage(date: string, reports: string[], pagesUrl?: string): string { const PAGES_URL = (pagesUrl ?? process.env["PAGES_URL"] ?? PAGES_URL_DEFAULT).replace(/\/$/, ""); const baseReports = reports.filter((r) => !r.endsWith("-en")); @@ -86,13 +155,53 @@ export function buildMessage(date: string, reports: string[], pagesUrl?: string) return lines.join("\n"); } -async function main(): Promise { - const BOT_TOKEN = process.env["TELEGRAM_BOT_TOKEN"] ?? ""; - if (!BOT_TOKEN) { - console.log("[notify] TELEGRAM_BOT_TOKEN not set — skipping."); - return; +function buildSlackMessage(date: string, reports: string[], pagesUrl: string): string { + const baseReports = reports.filter((r) => !r.endsWith("-en")); + const isWeekly = baseReports.includes("ai-weekly"); + const isMonthly = baseReports.includes("ai-monthly"); + + const icon = isMonthly ? "📆" : isWeekly ? "📅" : "📡"; + const suffix = isMonthly ? " Monthly" : isWeekly ? " Weekly" : ""; + const lines: string[] = [`${icon} *agents-radar${suffix} · ${date}*\n`]; + + const ordered = [ + ...baseReports.filter((r) => !r.includes("weekly") && !r.includes("monthly")), + ...baseReports.filter((r) => r.includes("weekly") || r.includes("monthly")), + ]; + + for (const r of ordered) { + const emoji = REPORT_EMOJIS[r] ?? "📄"; + const label = EN_LABELS[r] ?? r; + const url = `${pagesUrl}/#${date}/${r}`; + lines.push(`${emoji} <${url}|${label}>`); } + lines.push(`\n<${pagesUrl}|🌐 Web UI> · <${pagesUrl}/feed.xml|⊕ RSS>`); + return lines.join("\n"); +} + +function buildDiscordDescription(date: string, reports: string[], pagesUrl: string): string { + const baseReports = reports.filter((r) => !r.endsWith("-en")); + const ordered = [ + ...baseReports.filter((r) => !r.includes("weekly") && !r.includes("monthly")), + ...baseReports.filter((r) => r.includes("weekly") || r.includes("monthly")), + ]; + + return ordered + .map((r) => { + const emoji = REPORT_EMOJIS[r] ?? "📄"; + const label = EN_LABELS[r] ?? r; + const url = `${pagesUrl}/#${date}/${r}`; + return `${emoji} [${label}](${url})`; + }) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { if (!fs.existsSync("manifest.json")) { console.log("[notify] manifest.json not found — skipping."); return; @@ -107,12 +216,50 @@ async function main(): Promise { console.log("[notify] manifest is empty — skipping."); return; } + const { date, reports } = latest; - const text = buildMessage(date, reports); + const PAGES_URL = (process.env["PAGES_URL"] ?? PAGES_URL_DEFAULT).replace(/\/$/, ""); + const tasks: Promise[] = []; + + // Telegram + const telegramToken = process.env["TELEGRAM_BOT_TOKEN"] ?? ""; + if (telegramToken) { + const text = buildMessage(date, reports, PAGES_URL); + console.log(`[notify] Sending Telegram message for ${date}…`); + tasks.push(sendTelegram(text).catch((e) => console.error("[notify/telegram]", e))); + } + + // Slack + const slackUrl = process.env["SLACK_WEBHOOK_URL"] ?? ""; + if (slackUrl) { + const text = buildSlackMessage(date, reports, PAGES_URL); + console.log(`[notify] Sending Slack message for ${date}…`); + tasks.push(sendSlack(slackUrl, text).catch((e) => console.error("[notify/slack]", e))); + } + + // Discord + const discordUrl = process.env["DISCORD_WEBHOOK_URL"] ?? ""; + if (discordUrl) { + const isWeekly = reports.includes("ai-weekly"); + const isMonthly = reports.includes("ai-monthly"); + const suffix = isMonthly ? " Monthly" : isWeekly ? " Weekly" : ""; + const title = `📡 agents-radar${suffix} · ${date}`; + const description = buildDiscordDescription(date, reports, PAGES_URL); + console.log(`[notify] Sending Discord message for ${date}…`); + tasks.push( + sendDiscord(discordUrl, title, description, PAGES_URL).catch((e) => + console.error("[notify/discord]", e), + ), + ); + } + + if (tasks.length === 0) { + console.log("[notify] No notification channels configured — skipping."); + return; + } - console.log(`[notify] Sending Telegram message for ${date} (${reports.length} reports)…`); - await sendTelegram(text); - console.log("[notify] Done!"); + await Promise.allSettled(tasks); + console.log(`[notify] Done! Sent to ${tasks.length} channel(s).`); } main().catch((e: unknown) => { diff --git a/src/prompts.ts b/src/prompts.ts index d55f778..caec1b7 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -866,3 +866,53 @@ ${storiesText} 语言要求:中文,简洁专业,保留所有原文链接。 `; } + +// --------------------------------------------------------------------------- +// MCP Ecosystem +// --------------------------------------------------------------------------- + +export function buildMcpComparisonPrompt( + digests: RepoDigest[], + dateStr: string, + lang: "zh" | "en" = "zh", +): string { + const summaries = digests + .map((d) => `### ${d.config.name} (${d.config.repo})\n${d.summary}`) + .join("\n\n---\n\n"); + + if (lang === "en") { + return `You are an expert on the Model Context Protocol (MCP) ecosystem. Based on the following per-repo summaries for ${dateStr}, produce a cross-project comparison: + +${summaries} + +--- + +Generate a structured English analysis: + +1. **Ecosystem Highlights** — 2-3 sentences on the most important MCP developments today +2. **Spec & SDK Progress** — Key changes to the specification and official SDKs +3. **Server Ecosystem** — Notable new servers, integrations, or community contributions +4. **Cross-Project Themes** — Common patterns, recurring issues, or coordinated efforts +5. **Developer Impact** — What these changes mean for MCP adopters and tool builders + +Style: concise and professional, include GitHub links where possible. +`; + } + + return `你是 Model Context Protocol (MCP) 生态的技术分析师。请根据以下 ${dateStr} 各仓库的总结,生成一份横向对比分析: + +${summaries} + +--- + +请生成结构清晰的中文分析报告: + +1. **今日速览** — 2-3 句话概括今日 MCP 生态最重要的动态 +2. **规范与 SDK 进展** — 规范和官方 SDK 的重要变更 +3. **Server 生态** — 值得关注的新 Server、集成或社区贡献 +4. **跨项目主题** — 各项目间的共同模式、重复出现的问题或协同工作 +5. **开发者影响** — 这些变更对 MCP 使用者和工具开发者意味着什么 + +语言要求:中文,简洁专业,保留所有 GitHub 链接。 +`; +} diff --git a/src/report-builders.ts b/src/report-builders.ts index 5b8d48a..a2b76fe 100644 --- a/src/report-builders.ts +++ b/src/report-builders.ts @@ -154,3 +154,61 @@ export function buildOpenclawReportContent( footer ); } + +// --------------------------------------------------------------------------- +// MCP Ecosystem Report +// --------------------------------------------------------------------------- + +export function buildMcpReportContent( + mcpDigests: RepoDigest[], + comparison: string, + utcStr: string, + dateStr: string, + footer: string, + lang: "zh" | "en" = "zh", +): string { + const repoLinks = mcpDigests + .map((d) => `- [${d.config.name}](https://github.com/${d.config.repo})`) + .join("\n"); + + const t = + lang === "en" + ? { + title: `# MCP Ecosystem Digest ${dateStr}\n\n`, + meta: `> Generated: ${utcStr} UTC | Projects covered: ${mcpDigests.length}\n\n`, + comparison: `## Cross-Project Analysis\n\n`, + detail: `## Per-Project Reports\n\n`, + } + : { + title: `# MCP 生态日报 ${dateStr}\n\n`, + meta: `> 生成时间: ${utcStr} UTC | 覆盖项目: ${mcpDigests.length} 个\n\n`, + comparison: `## 横向对比\n\n`, + detail: `## 各项目详细报告\n\n`, + }; + + const sections = mcpDigests + .map((d) => + [ + `
`, + `${d.config.name}${d.config.repo}`, + ``, + d.summary, + ``, + `
`, + ].join("\n"), + ) + .join("\n\n"); + + return ( + t.title + + t.meta + + `${repoLinks}\n\n` + + `---\n\n` + + t.comparison + + comparison + + `\n\n---\n\n` + + t.detail + + sections + + footer + ); +}