From 9bca22e2efe1edd4fb7d827138e84b42efb538fd Mon Sep 17 00:00:00 2001 From: smartchoice Date: Mon, 2 Mar 2026 13:21:03 +0800 Subject: [PATCH] feat: config-driven architecture + RSS + notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to the fork: - Extract all tracked repos to config/sources.yaml (YAML-driven) - Add MCP ecosystem tracking (6 repos: spec, SDKs, servers) - Default LLM to Kimi Code API (ANTHROPIC_BASE_URL override) - Add Atom/RSS feed generation (feed.xml) - Add Slack/Discord/Telegram notification support - Generalize pipeline to support N groups (not just CLI + OpenClaw) - Enrich manifest.json with labels and emojis per report New files: - config/sources.yaml — all tracking sources configuration - src/config.ts — YAML config loader with validation - src/rss.ts — Atom feed generator - src/notify.ts — webhook notification dispatcher Co-Authored-By: Claude Opus 4.6 --- .github/workflows/daily-digest.yml | 9 +- config/sources.yaml | 198 +++++++++ package.json | 3 +- pnpm-lock.yaml | 10 + src/config.ts | 118 +++++ src/generate-manifest.ts | 24 +- src/index.ts | 683 ++++++++++++++--------------- src/notify.ts | 93 ++++ src/rss.ts | 85 ++++ 9 files changed, 853 insertions(+), 370 deletions(-) create mode 100644 config/sources.yaml create mode 100644 src/config.ts create mode 100644 src/notify.ts create mode 100644 src/rss.ts diff --git a/.github/workflows/daily-digest.yml b/.github/workflows/daily-digest.yml index ff6d17c..cc404b6 100644 --- a/.github/workflows/daily-digest.yml +++ b/.github/workflows/daily-digest.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest # Web content fetching adds ~60-120 s of HTTP requests on incremental runs; # the first-ever run (fetching 50 articles) may need up to 20 min. - timeout-minutes: 25 + timeout-minutes: 30 steps: - name: Checkout @@ -38,14 +38,19 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_MODEL: ${{ secrets.ANTHROPIC_MODEL }} DIGEST_REPO: ${{ github.repository }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} run: pnpm start - name: Commit digest files run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add digests/ + git add digests/ feed.xml if git diff --cached --quiet; then echo "No new digest files to commit" else diff --git a/config/sources.yaml b/config/sources.yaml new file mode 100644 index 0000000..ccd5a87 --- /dev/null +++ b/config/sources.yaml @@ -0,0 +1,198 @@ +# agents-radar 追踪来源设定 +# 编辑此档即可增减追踪标的,无需修改程式码 + +# ============================================================ +# 报告分组 +# ============================================================ +groups: + # ── AI CLI 工具(原版 CLI_REPOS)────────────────────────── + - id: ai-cli + name: "AI CLI 工具" + reportFile: "ai-cli.md" + issueLabel: "digest" + issueEmoji: "📊" + promptStyle: "cli" + repos: + - id: claude-code + repo: anthropics/claude-code + name: Claude Code + - id: codex + repo: openai/codex + name: OpenAI Codex + - id: gemini-cli + repo: google-gemini/gemini-cli + name: Gemini CLI + - id: kimi-cli + repo: MoonshotAI/kimi-cli + name: Kimi Code CLI + - id: opencode + repo: anomalyco/opencode + name: OpenCode + - id: qwen-code + repo: QwenLM/qwen-code + name: Qwen Code + + # ── OpenClaw 生态(原版 OPENCLAW + PEERS)───────────────── + - id: ai-agents + name: "OpenClaw 生态" + reportFile: "ai-agents.md" + issueLabel: "openclaw" + issueEmoji: "🦞" + promptStyle: "peer" + # 第一个 repo 作为主角(深度报告),其余为 peers + primaryRepo: openclaw + repos: + - id: openclaw + repo: openclaw/openclaw + name: OpenClaw + paginated: true + - id: nanobot + repo: HKUDS/nanobot + name: NanoBot + paginated: true + - id: zeroclaw + repo: zeroclaw-labs/zeroclaw + name: Zeroclaw + - id: picoclaw + repo: sipeed/picoclaw + name: PicoClaw + paginated: true + - id: nanoclaw + repo: qwibitai/nanoclaw + name: NanoClaw + - id: ironclaw + repo: nearai/ironclaw + name: IronClaw + - id: lobsterai + repo: netease-youdao/LobsterAI + name: LobsterAI + - id: tinyclaw + repo: TinyAGI/tinyclaw + name: TinyClaw + - id: copaw + repo: agentscope-ai/CoPaw + name: CoPaw + - id: zeptoclaw + repo: qhkm/zeptoclaw + name: ZeptoClaw + - id: easyclaw + repo: gaoyangz77/easyclaw + name: EasyClaw + + # ── MCP 生态系统(新增)──────────────────────────────────── + - id: mcp-ecosystem + name: "MCP 生态系统" + reportFile: "ai-mcp.md" + issueLabel: "mcp" + issueEmoji: "🔌" + promptStyle: "cli" + repos: + - id: mcp-spec + repo: modelcontextprotocol/modelcontextprotocol + name: MCP 规范 + paginated: true + - 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 官方 Servers + paginated: true + - id: github-mcp + repo: github/github-mcp-server + name: GitHub MCP Server + - id: context7 + repo: upstash/context7 + name: Context7 + +# ============================================================ +# Claude Code Skills(特殊追踪,无日期过滤) +# ============================================================ +skills: + repo: anthropics/skills + +# ============================================================ +# 网站内容追踪 +# ============================================================ +websites: + anthropic: + name: "Anthropic (Claude)" + sitemapUrl: "https://www.anthropic.com/sitemap.xml" + prefixes: + - /news/ + - /research/ + - /engineering/ + - /learn/ + metadataOnly: false + + openai: + name: "OpenAI" + sitemapUrl: "https://openai.com/sitemap.xml" + subSitemapNames: + - research + - publication + - release + - company + - engineering + - milestone + - learn-guides + - safety + - product + subSitemapTemplate: "https://openai.com/sitemap.xml/{name}/" + metadataOnly: true + +# ============================================================ +# GitHub Trending 搜寻关键字 +# ============================================================ +trending: + searchQueries: + - query: "topic:llm" + label: llm + - query: "topic:ai-agent" + label: ai-agent + - query: "topic:mcp-server" + label: mcp-server + - query: "topic:rag" + label: rag + - query: "topic:vector-database" + label: vector-db + - query: "topic:large-language-model" + label: llm-model + - query: "topic:machine-learning" + label: ml + +# ============================================================ +# 通知频道(环境变量启用) +# ============================================================ +notifications: + slack: + enabled: false + discord: + enabled: false + telegram: + enabled: false + +# ============================================================ +# LLM 设定 +# ============================================================ +llm: + # Kimi Code API(兼容 Anthropic SDK) + defaultBaseUrl: "https://api.kimi.com/coding/" + defaultModel: "kimi-k2-0711-preview" + concurrency: 5 + defaultMaxTokens: 4096 + trendingMaxTokens: 6144 + webMaxTokens: 8192 + +# ============================================================ +# RSS Feed 设定 +# ============================================================ +rss: + title: "agents-radar AI 生态日报" + description: "每日 AI 编码工具、MCP 生态、开源趋势追踪" + link: "https://howardpen9.github.io/agents-radar/" + language: "zh-CN" + maxItems: 30 diff --git a/package.json b/package.json index 9ee66bf..3103656 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "manifest": "tsx src/generate-manifest.ts" }, "dependencies": { - "@anthropic-ai/sdk": "^0.36.3" + "@anthropic-ai/sdk": "^0.36.3", + "yaml": "^2.8.2" }, "devDependencies": { "@eslint/js": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2a5d68..3332f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.36.3 version: 0.36.3 + yaml: + specifier: ^2.8.2 + version: 2.8.2 devDependencies: '@eslint/js': specifier: ^9 @@ -820,6 +823,11 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1558,4 +1566,6 @@ snapshots: word-wrap@1.2.5: {} + yaml@2.8.2: {} + yocto-queue@0.1.0: {} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ede490e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,118 @@ +/** + * Config loader — reads config/sources.yaml and provides typed access. + * Env vars override notification settings and LLM endpoint. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { RepoConfig } from "./github.ts"; + +// ── Types ──────────────────────────────────────────────── + +export interface GroupConfig { + id: string; + name: string; + reportFile: string; + issueLabel: string; + issueEmoji: string; + promptStyle: "cli" | "peer"; + primaryRepo?: string; // For peer groups: id of the primary project + repos: RepoConfig[]; +} + +export interface WebsiteConfig { + name: string; + sitemapUrl: string; + prefixes?: string[]; + subSitemapNames?: string[]; + subSitemapTemplate?: string; + metadataOnly?: boolean; +} + +export interface SearchQuery { + query: string; + label: string; +} + +export interface LlmConfig { + defaultBaseUrl: string; + defaultModel: string; + concurrency: number; + defaultMaxTokens: number; + trendingMaxTokens: number; + webMaxTokens: number; +} + +export interface RssConfig { + title: string; + description: string; + link: string; + language: string; + maxItems: number; +} + +export interface RadarConfig { + groups: GroupConfig[]; + skills: { repo: string }; + websites: Record; + trending: { searchQueries: SearchQuery[] }; + notifications: { + slack: { enabled: boolean }; + discord: { enabled: boolean }; + telegram: { enabled: boolean }; + }; + llm: LlmConfig; + rss: RssConfig; +} + +// ── Loader ─────────────────────────────────────────────── + +let _config: RadarConfig | null = null; + +export function loadConfig(configPath?: string): RadarConfig { + if (_config) return _config; + + const file = configPath ?? path.join(process.cwd(), "config", "sources.yaml"); + const raw = fs.readFileSync(file, "utf-8"); + const parsed = parseYaml(raw) as RadarConfig; + + // Validate groups + for (const group of parsed.groups) { + if (!group.id || !group.repos?.length) { + throw new Error(`Invalid group: missing id or repos in "${group.id ?? "unknown"}"`); + } + for (const repo of group.repos) { + if (!repo.repo.includes("/")) { + throw new Error(`Invalid repo format (need owner/repo): ${repo.repo}`); + } + } + } + + // Env overrides for notifications + if (process.env["SLACK_WEBHOOK_URL"]) { + parsed.notifications.slack.enabled = true; + } + if (process.env["DISCORD_WEBHOOK_URL"]) { + parsed.notifications.discord.enabled = true; + } + if (process.env["TELEGRAM_BOT_TOKEN"] && process.env["TELEGRAM_CHAT_ID"]) { + parsed.notifications.telegram.enabled = true; + } + + _config = parsed; + return parsed; +} + +/** Get all repos across all groups as flat list with RepoConfig shape. */ +export function getAllRepos(config: RadarConfig): RepoConfig[] { + return config.groups.flatMap((g) => g.repos); +} + +/** Get search queries from config (falls back to hardcoded if missing). */ +export function getSearchQueries(config: RadarConfig): Array<{ q: string; label: string }> { + return config.trending.searchQueries.map((sq) => ({ + q: sq.query, + label: sq.label, + })); +} diff --git a/src/generate-manifest.ts b/src/generate-manifest.ts index df5d090..a6db521 100644 --- a/src/generate-manifest.ts +++ b/src/generate-manifest.ts @@ -1,14 +1,32 @@ import fs from "fs"; import path from "path"; +import { loadConfig } from "./config.ts"; const DIGESTS_DIR = "digests"; const MANIFEST_PATH = "manifest.json"; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -const REPORT_FILES = ["ai-cli", "ai-agents", "ai-web", "ai-trending"] as const; + +// Build report file list from config + fixed reports +const config = loadConfig(); +const REPORT_FILES = [ + ...config.groups.map((g) => ({ + file: g.reportFile.replace(".md", ""), + label: g.name, + emoji: g.issueEmoji, + })), + { file: "ai-web", label: "官网动态", emoji: "🌐" }, + { file: "ai-trending", label: "开源趋势", emoji: "📈" }, +]; + +interface ReportEntry { + file: string; + label: string; + emoji: string; +} interface DateEntry { date: string; - reports: string[]; + reports: ReportEntry[]; } interface Manifest { @@ -22,7 +40,7 @@ const entries = fs .sort() .reverse() .map((date) => { - const reports = REPORT_FILES.filter((r) => fs.existsSync(path.join(DIGESTS_DIR, date, `${r}.md`))); + const reports = REPORT_FILES.filter((r) => fs.existsSync(path.join(DIGESTS_DIR, date, `${r.file}.md`))); return { date, reports }; }) .filter((e) => e.reports.length > 0); diff --git a/src/index.ts b/src/index.ts index dc7f159..afb5bff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ /** - * agents-radar: daily digest for AI CLI tools and OpenClaw. + * agents-radar: daily digest for AI ecosystem. + * + * Config-driven version: reads tracked repos from config/sources.yaml. + * Supports Kimi Code API (default) or any Anthropic-compatible endpoint. * * Env vars: - * ANTHROPIC_API_KEY - API key (Anthropic or Kimi Code) - * ANTHROPIC_BASE_URL - Endpoint override (e.g. https://api.kimi.com/coding/) - * ANTHROPIC_MODEL - Model name (default: claude-sonnet-4-6) + * ANTHROPIC_API_KEY - API key (Kimi Code or Anthropic) + * ANTHROPIC_BASE_URL - Endpoint override (default: from config) + * ANTHROPIC_MODEL - Model name (default: from config) * GITHUB_TOKEN - GitHub token for API access and issue creation * DIGEST_REPO - owner/repo where digest issues are posted (optional) */ @@ -31,45 +34,9 @@ import { import { callLlm, saveFile, autoGenFooter } from "./report.ts"; import { loadWebState, saveWebState, fetchSiteContent, type WebFetchResult, type WebState } from "./web.ts"; import { fetchTrendingData, type TrendingData } from "./trending.ts"; - -// --------------------------------------------------------------------------- -// Repo config -// --------------------------------------------------------------------------- - -/** AI CLI tools — included in per-tool digests and cross-tool comparison. */ -const CLI_REPOS: RepoConfig[] = [ - { id: "claude-code", repo: "anthropics/claude-code", name: "Claude Code" }, - { id: "codex", repo: "openai/codex", name: "OpenAI Codex" }, - { id: "gemini-cli", repo: "google-gemini/gemini-cli", name: "Gemini CLI" }, - { id: "kimi-cli", repo: "MoonshotAI/kimi-cli", name: "Kimi Code CLI" }, - { id: "opencode", repo: "anomalyco/opencode", name: "OpenCode" }, - { id: "qwen-code", repo: "QwenLM/qwen-code", name: "Qwen Code" }, -]; - -/** OpenClaw — high-volume project tracked separately with its own prompt. */ -const OPENCLAW: RepoConfig = { - id: "openclaw", - repo: "openclaw/openclaw", - name: "OpenClaw", - paginated: true, -}; - -/** Peer projects in the personal AI assistant / agent space — tracked for cross-ecosystem comparison. */ -const OPENCLAW_PEERS: RepoConfig[] = [ - { id: "nanobot", repo: "HKUDS/nanobot", name: "NanoBot", paginated: true }, - { id: "zeroclaw", repo: "zeroclaw-labs/zeroclaw", name: "Zeroclaw" }, - { id: "picoclaw", repo: "sipeed/picoclaw", name: "PicoClaw", paginated: true }, - { id: "nanoclaw", repo: "qwibitai/nanoclaw", name: "NanoClaw" }, - { id: "ironclaw", repo: "nearai/ironclaw", name: "IronClaw" }, - { id: "lobsterai", repo: "netease-youdao/LobsterAI", name: "LobsterAI" }, - { id: "tinyclaw", repo: "TinyAGI/tinyclaw", name: "TinyClaw" }, - { id: "copaw", repo: "agentscope-ai/CoPaw", name: "CoPaw" }, - { id: "zeptoclaw", repo: "qhkm/zeptoclaw", name: "ZeptoClaw" }, - { id: "easyclaw", repo: "gaoyangz77/easyclaw", name: "EasyClaw" }, -]; - -/** Claude Code Skills — trending skills tracked separately, no date filter. */ -const CLAUDE_SKILLS_REPO = "anthropics/skills"; +import { loadConfig, type GroupConfig } from "./config.ts"; +import { buildFeedEntry, saveFeed, type FeedEntry } from "./rss.ts"; +import { sendNotifications, type DigestNotification } from "./notify.ts"; // --------------------------------------------------------------------------- // Helpers @@ -97,52 +64,54 @@ interface RepoFetch { // --------------------------------------------------------------------------- async function fetchAllData( + config: ReturnType, since: Date, webState: WebState, ): Promise<{ - fetched: RepoFetch[]; + groupFetched: Map; skillsData: { prs: GitHubItem[]; issues: GitHubItem[] }; webResults: WebFetchResult[]; trendingData: TrendingData; }> { - const allConfigs = [...CLI_REPOS, OPENCLAW, ...OPENCLAW_PEERS]; - console.log(` Tracking: ${allConfigs.map((r) => r.id).join(", ")}, claude-code-skills, web`); + const allRepos = config.groups.flatMap((g) => g.repos); + console.log(` Tracking: ${allRepos.map((r) => r.id).join(", ")}, claude-code-skills, web`); + + // Fetch all repos across all groups + const allFetchPromises = config.groups.flatMap((group) => + group.repos.map(async (cfg) => { + const [issuesRaw, prs, releases] = await Promise.all([ + fetchRecentItems(cfg, "issues", since), + fetchRecentItems(cfg, "pulls", since), + fetchRecentReleases(cfg.repo, since), + ]); + const issues = issuesRaw.filter((i) => !i.pull_request); + console.log(` [${cfg.id}] issues: ${issues.length}, prs: ${prs.length}, releases: ${releases.length}`); + return { groupId: group.id, fetch: { cfg, issues, prs, releases } as RepoFetch }; + }), + ); - const [fetched, skillsData, webResults, trendingData] = await Promise.all([ - Promise.all( - allConfigs.map(async (cfg) => { - const [issuesRaw, prs, releases] = await Promise.all([ - fetchRecentItems(cfg, "issues", since), - fetchRecentItems(cfg, "pulls", since), - fetchRecentReleases(cfg.repo, since), - ]); - const issues = issuesRaw.filter((i) => !i.pull_request); - console.log( - ` [${cfg.id}] issues: ${issues.length}, prs: ${prs.length}, releases: ${releases.length}`, - ); - return { cfg, issues, prs, releases }; - }), - ), - fetchSkillsData(CLAUDE_SKILLS_REPO).then((d) => { + const websiteIds = Object.keys(config.websites) as Array<"anthropic" | "openai">; + + const [repoResults, skillsData, webResults, trendingData] = await Promise.all([ + Promise.all(allFetchPromises), + fetchSkillsData(config.skills.repo).then((d) => { console.log(` [claude-code-skills] prs: ${d.prs.length}, issues: ${d.issues.length}`); return d; }), - Promise.all([ - fetchSiteContent("anthropic", webState).catch((err): WebFetchResult => { - console.error(` [web/anthropic] fetch failed: ${err}`); - return { - site: "anthropic", - siteName: "Anthropic (Claude)", - isFirstRun: false, - newItems: [], - totalDiscovered: 0, - }; - }), - fetchSiteContent("openai", webState).catch((err): WebFetchResult => { - console.error(` [web/openai] fetch failed: ${err}`); - return { site: "openai", siteName: "OpenAI", isFirstRun: false, newItems: [], totalDiscovered: 0 }; - }), - ]), + Promise.all( + websiteIds.map((site) => + fetchSiteContent(site, webState).catch((err): WebFetchResult => { + console.error(` [web/${site}] fetch failed: ${err}`); + return { + site, + siteName: config.websites[site]?.name ?? site, + isFirstRun: false, + newItems: [], + totalDiscovered: 0, + }; + }), + ), + ), fetchTrendingData().catch( (): TrendingData => ({ trendingRepos: [], @@ -152,213 +121,268 @@ async function fetchAllData( ), ]); - return { fetched, skillsData, webResults, trendingData }; + // Group results by group ID + const groupFetched = new Map(); + for (const { groupId, fetch } of repoResults) { + const existing = groupFetched.get(groupId) ?? []; + existing.push(fetch); + groupFetched.set(groupId, existing); + } + + return { groupFetched, skillsData, webResults, trendingData }; } // --------------------------------------------------------------------------- -// Phase 2: LLM summaries +// Phase 2: LLM summaries (per group) // --------------------------------------------------------------------------- -async function generateSummaries( - fetchedCli: RepoFetch[], - fetchedOpenclaw: RepoFetch, +async function generateGroupSummaries( + group: GroupConfig, + fetched: RepoFetch[], skillsData: { prs: GitHubItem[]; issues: GitHubItem[] }, - fetchedPeers: RepoFetch[], - trendingData: TrendingData, dateStr: string, ): Promise<{ - cliDigests: RepoDigest[]; - openclawSummary: string; + digests: RepoDigest[]; + primarySummary: string; skillsSummary: string; - peerDigests: RepoDigest[]; - trendingSummary: string; }> { - const [cliDigests, openclawSummary, skillsSummary, peerDigests, trendingSummary] = await Promise.all([ - Promise.all( - fetchedCli.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: "过去24小时无活动。" }; - } - console.log(` [${cfg.id}] Calling LLM for summary...`); - try { - const summary = await callLlm(buildCliPrompt(cfg, issues, prs, releases, dateStr)); - return { config: cfg, issues, prs, releases, summary }; - } catch (err) { - console.error(` [${cfg.id}] LLM call failed: ${err}`); - return { config: cfg, issues, prs, releases, summary: "⚠️ 摘要生成失败。" }; - } - }), - ), - (async () => { - const { cfg, issues, prs, releases } = fetchedOpenclaw; + const primary = group.primaryRepo ? fetched.find((f) => f.cfg.id === group.primaryRepo) : undefined; + const peers = group.primaryRepo ? fetched.filter((f) => f.cfg.id !== group.primaryRepo) : fetched; + const isCliStyle = group.promptStyle === "cli"; + + // Generate summaries for all repos in parallel + const digestPromises = (group.primaryRepo ? peers : fetched).map( + async ({ cfg, issues, prs, releases }): Promise => { const hasData = issues.length || prs.length || releases.length; if (!hasData) { - console.log(` [openclaw] No activity, skipping LLM call`); - return "过去24小时无活动。"; - } - console.log(` [openclaw] Calling LLM for OpenClaw report...`); - try { - return await callLlm(buildPeerPrompt(cfg, issues, prs, releases, dateStr, 50, 30)); - } catch (err) { - console.error(` [openclaw] LLM call failed: ${err}`); - return "⚠️ 摘要生成失败。"; + console.log(` [${cfg.id}] No activity, skipping LLM call`); + return { config: cfg, issues, prs, releases, summary: "过去24小时无活动。" }; } - })(), - (async () => { - console.log(" [claude-code-skills] Calling LLM for skills report..."); + console.log(` [${cfg.id}] Calling LLM for summary...`); try { - return await callLlm(buildSkillsPrompt(skillsData.prs, skillsData.issues, dateStr)); + const prompt = isCliStyle + ? buildCliPrompt(cfg, issues, prs, releases, dateStr) + : buildPeerPrompt(cfg, issues, prs, releases, dateStr); + const summary = await callLlm(prompt); + return { config: cfg, issues, prs, releases, summary }; } catch (err) { - console.error(` [claude-code-skills] LLM call failed: ${err}`); - return "⚠️ Skills 摘要生成失败。"; + console.error(` [${cfg.id}] LLM call failed: ${err}`); + return { config: cfg, issues, prs, releases, summary: "⚠️ 摘要生成失败。" }; } - })(), - Promise.all( - fetchedPeers.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: "过去24小时无活动。" }; - } - console.log(` [${cfg.id}] Calling LLM for peer summary...`); - try { - return { - config: cfg, - issues, - prs, - releases, - summary: await callLlm(buildPeerPrompt(cfg, issues, prs, releases, dateStr)), - }; - } catch (err) { - console.error(` [${cfg.id}] LLM call failed: ${err}`); - return { config: cfg, issues, prs, releases, summary: "⚠️ 摘要生成失败。" }; - } - }), - ), - (async () => { - const hasData = trendingData.trendingRepos.length > 0 || trendingData.searchRepos.length > 0; - if (!hasData) return "⚠️ 今日趋势数据获取失败,无法生成报告。"; - console.log(" [trending] Calling LLM for trending report..."); + }, + ); + + // Primary repo summary (for peer-style groups like OpenClaw) + let primarySummary = ""; + if (primary) { + const { cfg, issues, prs, releases } = primary; + const hasData = issues.length || prs.length || releases.length; + if (!hasData) { + primarySummary = "过去24小时无活动。"; + } else { + console.log(` [${cfg.id}] Calling LLM for primary report...`); try { - return await callLlm(buildTrendingPrompt(trendingData, dateStr), 6144); + primarySummary = await callLlm(buildPeerPrompt(cfg, issues, prs, releases, dateStr, 50, 30)); } catch (err) { - console.error(` [trending] LLM call failed: ${err}`); - return "⚠️ 趋势报告生成失败。"; + console.error(` [${cfg.id}] LLM call failed: ${err}`); + primarySummary = "⚠️ 摘要生成失败。"; } - })(), - ]); + } + } - return { cliDigests, openclawSummary, skillsSummary, peerDigests, trendingSummary }; + // Skills summary (only for groups containing claude-code) + let skillsSummary = ""; + const hasClaudeCode = fetched.some((f) => f.cfg.id === "claude-code"); + if (hasClaudeCode) { + console.log(" [claude-code-skills] Calling LLM for skills report..."); + try { + skillsSummary = await callLlm(buildSkillsPrompt(skillsData.prs, skillsData.issues, dateStr)); + } catch (err) { + console.error(` [claude-code-skills] LLM call failed: ${err}`); + skillsSummary = "⚠️ Skills 摘要生成失败。"; + } + } + + const digests = await Promise.all(digestPromises); + return { digests, primarySummary, skillsSummary }; } // --------------------------------------------------------------------------- -// Report content builders +// Phase 3 + 4: Comparison + Save (per group) // --------------------------------------------------------------------------- -function buildCliReportContent( - cliDigests: RepoDigest[], - skillsSummary: string, - comparison: string, - utcStr: string, +async function processGroup( + group: GroupConfig, + fetched: RepoFetch[], + skillsData: { prs: GitHubItem[]; issues: GitHubItem[] }, dateStr: string, - footer: string, -): string { - const repoLinks = - cliDigests.map((d) => `- [${d.config.name}](https://github.com/${d.config.repo})`).join("\n") + - `\n- [Claude Code Skills](https://github.com/${CLAUDE_SKILLS_REPO})`; - - const toolSections = cliDigests - .map((d) => { - const skillsSection = - d.config.id === "claude-code" - ? `## Claude Code Skills 社区热点\n\n> 数据来源: [anthropics/skills](https://github.com/${CLAUDE_SKILLS_REPO})\n\n${skillsSummary}\n\n---\n\n` - : ""; - return [ - `
`, - `${d.config.name}${d.config.repo}`, - ``, - skillsSection + d.summary, - ``, - `
`, - ].join("\n"); - }) - .join("\n\n"); - - return ( - `# AI CLI 工具社区动态日报 ${dateStr}\n\n` + - `> 生成时间: ${utcStr} UTC | 覆盖工具: ${cliDigests.length} 个\n\n` + - `${repoLinks}\n\n` + - `---\n\n` + - `## 横向对比\n\n` + - comparison + - `\n\n---\n\n` + - `## 各工具详细报告\n\n` + - toolSections + - footer - ); -} - -function buildOpenclawReportContent( - fetchedOpenclaw: RepoFetch, - peerDigests: RepoDigest[], - openclawSummary: string, - peersComparison: string, utcStr: string, - dateStr: string, + digestRepo: string, footer: string, -): string { - const { issues, prs } = fetchedOpenclaw; - - const peersRepoLinks = - `- [OpenClaw](https://github.com/${OPENCLAW.repo})\n` + - OPENCLAW_PEERS.map((p) => `- [${p.name}](https://github.com/${p.repo})`).join("\n"); - - const peerDetailSections = peerDigests - .map((d) => - [ - `
`, - `${d.config.name}${d.config.repo}`, - ``, - d.summary, - ``, - `
`, - ].join("\n"), - ) - .join("\n\n"); - - return ( - `# OpenClaw 生态日报 ${dateStr}\n\n` + - `> Issues: ${issues.length} | PRs: ${prs.length} | 覆盖项目: ${1 + OPENCLAW_PEERS.length} 个 | 生成时间: ${utcStr} UTC\n\n` + - `${peersRepoLinks}\n\n` + - `---\n\n` + - `## OpenClaw 项目深度报告\n\n` + - openclawSummary + - `\n\n---\n\n` + - `## 横向生态对比\n\n` + - peersComparison + - `\n\n---\n\n` + - `## 同赛道项目详细报告\n\n` + - peerDetailSections + - footer +): Promise<{ issueUrl: string | null; content: string }> { + // Phase 2: Generate summaries + const { digests, primarySummary, skillsSummary } = await generateGroupSummaries( + group, + fetched, + skillsData, + dateStr, ); + + const primary = group.primaryRepo ? fetched.find((f) => f.cfg.id === group.primaryRepo) : undefined; + + // Phase 3: Comparison + let content: string; + + if (group.primaryRepo && primary) { + // Peer-style group (like OpenClaw): primary deep report + peers comparison + const primaryDigest: RepoDigest = { + config: primary.cfg, + issues: primary.issues, + prs: primary.prs, + releases: primary.releases, + summary: primarySummary, + }; + + console.log(` [${group.id}] Calling LLM for peers comparison...`); + const comparison = await callLlm(buildPeersComparisonPrompt(primaryDigest, digests, dateStr)); + + const primaryCfg = primary.cfg; + const peersRepoLinks = + `- [${primaryCfg.name}](https://github.com/${primaryCfg.repo})\n` + + digests.map((d) => `- [${d.config.name}](https://github.com/${d.config.repo})`).join("\n"); + + const peerDetailSections = digests + .map((d) => + [ + `
`, + `${d.config.name}${d.config.repo}`, + ``, + d.summary, + ``, + `
`, + ].join("\n"), + ) + .join("\n\n"); + + content = + `# ${group.issueEmoji} ${primaryCfg.name} 生态日报 ${dateStr}\n\n` + + `> Issues: ${primary.issues.length} | PRs: ${primary.prs.length} | 覆盖项目: ${1 + digests.length} 个 | 生成时间: ${utcStr} UTC\n\n` + + `${peersRepoLinks}\n\n---\n\n` + + `## ${primaryCfg.name} 项目深度报告\n\n${primarySummary}\n\n---\n\n` + + `## 横向生态对比\n\n${comparison}\n\n---\n\n` + + `## 同赛道项目详细报告\n\n${peerDetailSections}${footer}`; + } else { + // CLI-style group: per-tool summaries + cross-tool comparison + console.log(` [${group.id}] Calling LLM for comparison...`); + const comparison = await callLlm(buildComparisonPrompt(digests, dateStr)); + + const repoLinks = digests.map((d) => `- [${d.config.name}](https://github.com/${d.config.repo})`).join("\n"); + + const toolSections = digests + .map((d) => { + const skillsSection = + d.config.id === "claude-code" && skillsSummary + ? `## Claude Code Skills 社区热点\n\n> 数据来源: [anthropics/skills](https://github.com/anthropics/skills)\n\n${skillsSummary}\n\n---\n\n` + : ""; + return [ + `
`, + `${d.config.name}${d.config.repo}`, + ``, + skillsSection + d.summary, + ``, + `
`, + ].join("\n"); + }) + .join("\n\n"); + + content = + `# ${group.issueEmoji} ${group.name}社区动态日报 ${dateStr}\n\n` + + `> 生成时间: ${utcStr} UTC | 覆盖工具: ${digests.length} 个\n\n` + + `${repoLinks}\n\n---\n\n` + + `## 横向对比\n\n${comparison}\n\n---\n\n` + + `## 各工具详细报告\n\n${toolSections}${footer}`; + } + + // Phase 4: Save + console.log(` Saved ${saveFile(content, dateStr, group.reportFile)}`); + + let issueUrl: string | null = null; + if (digestRepo) { + const title = `${group.issueEmoji} ${group.name}日报 ${dateStr}`; + issueUrl = await createGitHubIssue(title, content, group.issueLabel); + console.log(` Created ${group.id} issue: ${issueUrl}`); + } + + return { issueUrl, content }; } // --------------------------------------------------------------------------- -// Report savers (LLM call + file save + optional GitHub issue) +// Main // --------------------------------------------------------------------------- -async function saveWebReport( - webResults: WebFetchResult[], - webState: WebState, - utcStr: string, - dateStr: string, - digestRepo: string, - footer: string, -): Promise { - const hasNewContent = webResults.some((r) => r.newItems.length > 0); +async function main(): Promise { + requireEnv("GITHUB_TOKEN"); + requireEnv("ANTHROPIC_API_KEY"); + + const config = loadConfig(); + // Apply LLM config: set env defaults if not already set + if (!process.env["ANTHROPIC_BASE_URL"] && config.llm.defaultBaseUrl) { + process.env["ANTHROPIC_BASE_URL"] = config.llm.defaultBaseUrl; + } + if (!process.env["ANTHROPIC_MODEL"] && config.llm.defaultModel) { + process.env["ANTHROPIC_MODEL"] = config.llm.defaultModel; + } + + const now = new Date(); + const since = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const dateStr = new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); + const utcStr = now.toISOString().slice(0, 16).replace("T", " "); + const digestRepo = process.env["DIGEST_REPO"] ?? ""; + + console.log( + `[${now.toISOString()}] Starting digest | endpoint: ${process.env["ANTHROPIC_BASE_URL"] ?? "api.anthropic.com"}`, + ); + console.log(` Groups: ${config.groups.map((g) => g.name).join(", ")}`); + + // Phase 1: Fetch all data + const webState = loadWebState(); + const { groupFetched, skillsData, webResults, trendingData } = await fetchAllData(config, since, webState); + + const footer = autoGenFooter(); + const feedEntries: FeedEntry[] = []; + const notifyReports: DigestNotification["reports"] = []; + + // Phase 2-4: Process each group + for (const group of config.groups) { + const fetched = groupFetched.get(group.id) ?? []; + const { issueUrl, content } = await processGroup( + group, + fetched, + skillsData, + dateStr, + utcStr, + digestRepo, + footer, + ); + + // RSS entry + feedEntries.push( + buildFeedEntry(dateStr, `${group.name}日报`, content, issueUrl, config.rss.link, group.reportFile.replace(".md", "")), + ); + + // Notification entry + const summaryLines = content.split("\n").filter((l) => l.startsWith("> ") || l.startsWith("## ")); + notifyReports.push({ + label: group.name, + emoji: group.issueEmoji, + issueUrl: issueUrl ?? undefined, + summary: summaryLines.slice(0, 2).join(" ").slice(0, 200) || `${group.name}日报已生成`, + }); + } + + // Web report + const hasNewContent = webResults.some((r) => r.newItems.length > 0); if (hasNewContent) { console.log(" [web] Calling LLM for web content report..."); try { @@ -368,145 +392,76 @@ async function saveWebReport( const totalNew = webResults.reduce((sum, r) => sum + r.newItems.length, 0); const webContent = - `# AI 官方内容追踪报告 ${dateStr}\n\n` + + `# 🌐 AI 官方内容追踪报告 ${dateStr}\n\n` + `> ${mode} | 新增内容: ${totalNew} 篇 | 生成时间: ${utcStr} UTC\n\n` + `数据来源:\n` + - `- Anthropic: [anthropic.com](https://www.anthropic.com) — ` + - `新增 ${webResults.find((r) => r.site === "anthropic")?.newItems.length ?? 0} 篇` + - `(sitemap 共 ${webResults.find((r) => r.site === "anthropic")?.totalDiscovered ?? 0} 条)\n` + - `- OpenAI: [openai.com](https://openai.com) — ` + - `新增 ${webResults.find((r) => r.site === "openai")?.newItems.length ?? 0} 篇` + - `(sitemap 共 ${webResults.find((r) => r.site === "openai")?.totalDiscovered ?? 0} 条)\n\n` + - `---\n\n` + - webSummary + - footer; + webResults + .map( + (r) => + `- ${r.siteName}: 新增 ${r.newItems.length} 篇(sitemap 共 ${r.totalDiscovered} 条)`, + ) + .join("\n") + + `\n\n---\n\n${webSummary}${footer}`; console.log(` Saved ${saveFile(webContent, dateStr, "ai-web.md")}`); + let webIssueUrl: string | null = null; if (digestRepo) { - const webUrl = await createGitHubIssue( + webIssueUrl = await createGitHubIssue( `🌐 AI 官方内容追踪报告 ${dateStr}${isFirstRun ? "(首次全量)" : ""}`, webContent, "web", ); - console.log(` Created web issue: ${webUrl}`); + console.log(` Created web issue: ${webIssueUrl}`); } + + feedEntries.push(buildFeedEntry(dateStr, "AI 官方内容追踪", webContent, webIssueUrl, config.rss.link, "ai-web")); } catch (err) { console.error(` [web] Report generation failed: ${err}`); } } else { console.log(" [web] No new content detected, skipping report."); } - saveWebState(webState); - console.log(" [web] State saved."); -} -async function saveTrendingReport( - trendingData: TrendingData, - trendingSummary: string, - utcStr: string, - dateStr: string, - digestRepo: string, - footer: string, -): Promise { - const hasData = trendingData.trendingRepos.length > 0 || trendingData.searchRepos.length > 0; - if (!hasData) { - console.log(" [trending] No data available, skipping report."); - return; - } + // Trending report + const hasTrending = trendingData.trendingRepos.length > 0 || trendingData.searchRepos.length > 0; + if (hasTrending) { + console.log(" [trending] Calling LLM for trending report..."); + try { + const trendingSummary = await callLlm(buildTrendingPrompt(trendingData, dateStr), 6144); + const trendingContent = + `# 📈 AI 开源趋势日报 ${dateStr}\n\n` + + `> 数据来源: GitHub Trending + GitHub Search API | 生成时间: ${utcStr} UTC\n\n---\n\n` + + trendingSummary + + footer; - const trendingContent = - `# AI 开源趋势日报 ${dateStr}\n\n` + - `> 数据来源: GitHub Trending + GitHub Search API | 生成时间: ${utcStr} UTC\n\n` + - `---\n\n` + - trendingSummary + - footer; + console.log(` Saved ${saveFile(trendingContent, dateStr, "ai-trending.md")}`); - console.log(` Saved ${saveFile(trendingContent, dateStr, "ai-trending.md")}`); + let trendingIssueUrl: string | null = null; + if (digestRepo) { + trendingIssueUrl = await createGitHubIssue(`📈 AI 开源趋势日报 ${dateStr}`, trendingContent, "trending"); + console.log(` Created trending issue: ${trendingIssueUrl}`); + } - if (digestRepo) { - const trendingUrl = await createGitHubIssue(`📈 AI 开源趋势日报 ${dateStr}`, trendingContent, "trending"); - console.log(` Created trending issue: ${trendingUrl}`); + feedEntries.push( + buildFeedEntry(dateStr, "AI 开源趋势", trendingContent, trendingIssueUrl, config.rss.link, "ai-trending"), + ); + } catch (err) { + console.error(` [trending] Report generation failed: ${err}`); + } } -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- -async function main(): Promise { - requireEnv("GITHUB_TOKEN"); - requireEnv("ANTHROPIC_API_KEY"); - - const now = new Date(); - const since = new Date(now.getTime() - 24 * 60 * 60 * 1000); - const dateStr = new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); - const utcStr = now.toISOString().slice(0, 16).replace("T", " "); - const digestRepo = process.env["DIGEST_REPO"] ?? ""; + // RSS feed + saveFeed(config.rss, feedEntries); - console.log( - `[${now.toISOString()}] Starting digest | endpoint: ${process.env["ANTHROPIC_BASE_URL"] ?? "api.anthropic.com"}`, - ); - - // 1. Fetch all data in parallel - const webState = loadWebState(); - const { fetched, skillsData, webResults, trendingData } = 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 fetchedOpenclaw = fetched.find((f) => f.cfg.id === OPENCLAW.id)!; - const fetchedPeers = fetched.filter((f) => peerIds.has(f.cfg.id)); - - // 2. Generate per-repo LLM summaries in parallel - const { cliDigests, openclawSummary, skillsSummary, peerDigests, trendingSummary } = - await generateSummaries(fetchedCli, fetchedOpenclaw, skillsData, fetchedPeers, trendingData, dateStr); - - // 3. Generate cross-repo comparisons in parallel - console.log(" Calling LLM for CLI comparative analysis + peers comparison..."); - const openclawDigest: RepoDigest = { - config: OPENCLAW, - issues: fetchedOpenclaw.issues, - prs: fetchedOpenclaw.prs, - releases: fetchedOpenclaw.releases, - summary: openclawSummary, - }; - const [comparison, peersComparison] = await Promise.all([ - callLlm(buildComparisonPrompt(cliDigests, dateStr)), - callLlm(buildPeersComparisonPrompt(openclawDigest, peerDigests, dateStr)), - ]); - - const footer = autoGenFooter(); - - // 4. Build + save all reports - const digestContent = buildCliReportContent(cliDigests, skillsSummary, comparison, utcStr, dateStr, footer); - const openclawContent = buildOpenclawReportContent( - fetchedOpenclaw, - peerDigests, - openclawSummary, - peersComparison, - utcStr, - dateStr, - footer, - ); - - console.log(` Saved ${saveFile(digestContent, dateStr, "ai-cli.md")}`); - console.log(` Saved ${saveFile(openclawContent, dateStr, "ai-agents.md")}`); - - await saveWebReport(webResults, webState, utcStr, dateStr, digestRepo, footer); - await saveTrendingReport(trendingData, trendingSummary, utcStr, dateStr, digestRepo, footer); - - // 5. Create GitHub issues for CLI + OpenClaw - if (digestRepo) { - const cliUrl = await createGitHubIssue(`📊 AI CLI 工具社区动态日报 ${dateStr}`, digestContent, "digest"); - console.log(` Created CLI issue: ${cliUrl}`); - - const openclawUrl = await createGitHubIssue( - `🦞 OpenClaw 生态日报 ${dateStr}`, - openclawContent, - "openclaw", - ); - console.log(` Created OpenClaw issue: ${openclawUrl}`); + // Notifications + if (notifyReports.length > 0) { + await sendNotifications(config, { + dateStr, + reports: notifyReports, + pagesUrl: config.rss.link, + }); } console.log("Done!"); diff --git a/src/notify.ts b/src/notify.ts new file mode 100644 index 0000000..beeaaf9 --- /dev/null +++ b/src/notify.ts @@ -0,0 +1,93 @@ +/** + * Notification dispatcher — sends digest summaries to Slack, Discord, Telegram. + * All channels use native fetch(), zero extra dependencies. + */ + +import type { RadarConfig } from "./config.ts"; + +export interface DigestNotification { + dateStr: string; + reports: Array<{ + label: string; + emoji: string; + issueUrl?: string; + summary: string; // 2-3 sentence highlight + }>; + pagesUrl: string; +} + +async function notifySlack(webhookUrl: string, n: DigestNotification): Promise { + const text = n.reports + .map((r) => `${r.emoji} *${r.label}*\n${r.summary}${r.issueUrl ? `\n<${r.issueUrl}|查看完整报告>` : ""}`) + .join("\n\n"); + + await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `📡 agents-radar ${n.dateStr} 日报已更新\n\n${text}\n\n<${n.pagesUrl}|在线阅读>`, + }), + }); +} + +async function notifyDiscord(webhookUrl: string, n: DigestNotification): Promise { + const description = n.reports.map((r) => `${r.emoji} **${r.label}**\n${r.summary}`).join("\n\n"); + + await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + embeds: [ + { + title: `📡 ${n.dateStr} AI 生态日报`, + description, + url: n.pagesUrl, + color: 0xe8a03d, + footer: { text: "agents-radar" }, + }, + ], + }), + }); +} + +async function notifyTelegram(botToken: string, chatId: string, n: DigestNotification): Promise { + const text = [ + `📡 *${n.dateStr} AI 生态日报*`, + "", + ...n.reports.map((r) => `${r.emoji} *${r.label}*\n${r.summary}`), + "", + `[在线阅读](${n.pagesUrl})`, + ].join("\n"); + + await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text, parse_mode: "Markdown", disable_web_page_preview: true }), + }); +} + +export async function sendNotifications(config: RadarConfig, notification: DigestNotification): Promise { + const tasks: Promise[] = []; + + if (config.notifications.slack.enabled) { + const url = process.env["SLACK_WEBHOOK_URL"]; + if (url) tasks.push(notifySlack(url, notification).catch((e) => console.error(` [notify/slack]`, e))); + } + + if (config.notifications.discord.enabled) { + const url = process.env["DISCORD_WEBHOOK_URL"]; + if (url) tasks.push(notifyDiscord(url, notification).catch((e) => console.error(` [notify/discord]`, e))); + } + + if (config.notifications.telegram.enabled) { + const token = process.env["TELEGRAM_BOT_TOKEN"]; + const chatId = process.env["TELEGRAM_CHAT_ID"]; + if (token && chatId) + tasks.push(notifyTelegram(token, chatId, notification).catch((e) => console.error(` [notify/telegram]`, e))); + } + + if (tasks.length > 0) { + await Promise.allSettled(tasks); + console.log(` [notify] Sent ${tasks.length} notification(s)`); + } +} diff --git a/src/rss.ts b/src/rss.ts new file mode 100644 index 0000000..af080ab --- /dev/null +++ b/src/rss.ts @@ -0,0 +1,85 @@ +/** + * Atom/RSS feed generator — produces feed.xml from daily digest reports. + */ + +import fs from "node:fs"; +import type { RssConfig } from "./config.ts"; + +export interface FeedEntry { + title: string; + link: string; + date: string; + summary: string; + category: string; +} + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function stripMarkdown(md: string, maxLen = 500): string { + return md + .replace(/#{1,6}\s/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/^[-*]\s/gm, "") + .replace(/\n+/g, " ") + .trim() + .slice(0, maxLen); +} + +export function generateAtomFeed(config: RssConfig, entries: FeedEntry[]): string { + const sorted = [...entries].sort((a, b) => b.date.localeCompare(a.date)).slice(0, config.maxItems); + const updated = sorted[0]?.date ?? new Date().toISOString(); + + const items = sorted + .map( + (e) => ` + ${escapeXml(e.title)} + + ${escapeXml(e.link)} + ${e.date} + ${escapeXml(e.summary)} + + `, + ) + .join("\n"); + + return ` + + ${escapeXml(config.title)} + ${escapeXml(config.description)} + + + ${config.link} + ${updated} + agents-radar +${items} +`; +} + +export function saveFeed(config: RssConfig, entries: FeedEntry[]): void { + const xml = generateAtomFeed(config, entries); + fs.writeFileSync("feed.xml", xml, "utf-8"); + console.log(` [rss] Saved feed.xml (${entries.length} entries)`); +} + +export function buildFeedEntry( + dateStr: string, + label: string, + content: string, + issueUrl: string | null, + siteUrl: string, + category: string, +): FeedEntry { + return { + title: `[${dateStr}] ${label}`, + link: issueUrl ?? `${siteUrl}#${dateStr}/${category}`, + date: new Date(`${dateStr}T00:00:00Z`).toISOString(), + summary: stripMarkdown(content), + category, + }; +}