
Trends Bulletin
- 155 installs
- 303 repo stars
- Updated April 20, 2026
- iamzhihuix/happy-claude-skills
Compile a concise trends bulletin from current signals so you can spot themes, risks, and opportunities before scoping a product or content plan.
About
trends-bulletin from happy-claude-skills helps agents draft periodic trend bulletins that distill emerging topics into a skimmable brief. It supports idea-stage research for content brands, SaaS positioning, and distribution planning without deep competitive tooling.
- Produces structured trend bulletins
- Supports early market scanning
- Summarizes signals into planning input
- Useful for content and product ideation
- Lightweight recurring research ritual
Trends Bulletin by the numbers
- 155 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,163 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzhihuix/happy-claude-skills --skill trends-bulletinAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 155 |
|---|---|
| repo stars | ★ 303 |
| Last updated | April 20, 2026 |
| Repository | iamzhihuix/happy-claude-skills ↗ |
What it does
Compile a concise trends bulletin from current signals so you can spot themes, risks, and opportunities before scoping a product or content plan.
Files
多平台热词速报
一键采集 6 个平台热门趋势,格式化后推送到 Telegram。
依赖
使用 Bun 运行时内置 fetch,无需安装额外依赖。
环境变量
必须配置以下环境变量(或在 .env 文件中设置):
| 变量 | 必须 | 说明 |
|---|---|---|
TELEGRAM_BOT_TOKEN | 是 | Telegram Bot Token |
TELEGRAM_CHAT_ID | 是 | Telegram 目标 Chat ID |
GROK_API_KEY | 否 | xAI Grok API Key(为空则跳过 AI 趋势分析) |
PRODUCT_HUNT_TOKEN | 否 | Product Hunt API Token(为空则跳过 PH) |
YOUTUBE_API_KEY | 否 | YouTube Data API Key(为空则跳过 YouTube) |
执行
Agent Execution Instructions: 1. Determine this SKILL.md file's directory path as {baseDir} 2. Script path = {baseDir}/scripts/main.ts 3. Replace all {baseDir} in this document with the actual path 4. Resolve ${BUN_X} runtime: if bun installed → bun; if npx available → npx -y bun; else suggest installing bun
${BUN_X} run {baseDir}/scripts/main.ts指定 .env 文件路径:
${BUN_X} --env-file=/path/to/.env run {baseDir}/scripts/main.tsDry-run 模式(只打印不发送):
${BUN_X} run {baseDir}/scripts/main.ts --dry-run输出格式
按平台分组推送到 Telegram,顶部附带 AI 分析的三大关键趋势(需配置 GROK_API_KEY):
🔥 热词速报
━━━ 🎯 三大关键趋势 ━━━
1️⃣ AI代理兴起
💡 核心观点:AI代理技术正推动自动化任务执行
📊 关键数据:GitHub BitNet 今日+2149⭐
❗ 为什么重要:降低部署成本,促进广泛应用
🔗 https://github.com/microsoft/BitNet
━━━ HuggingFace Spaces ━━━
1. microsoft/TRELLIS.2 (🔥315)
2. Qwen/Qwen-Image-Layered (🔥264)
━━━ 每日论文 ━━━
1. SWE-EVO: Benchmarking... (👍3)
━━━ Hacker News ━━━
1. Python 3.15's interpreter... (🔥102 | 💬23)
━━━ Product Hunt ━━━
1. DiffSense (🔺211)
━━━ Reddit 热帖 ━━━
1. [r/singularity] GPT-5.2 Pro... (🔺346 | 💬83)
━━━ GitHub Trending ━━━
1. rendercv/rendercv ⭐+1,797 Python自定义
脚本支持通过环境变量自定义:
REDDIT_SUBS- Reddit 子版列表,逗号分隔(默认:MachineLearning,LocalLLaMA,artificial,ChatGPT,singularity)ITEMS_PER_PLATFORM- 每个平台展示条数(默认:5)
示例
用户请求:
帮我发一下热词速报执行流程: 1. 检查环境变量是否配置(TELEGRAM_BOT_TOKEN、TELEGRAM_CHAT_ID) 2. 执行 ${BUN_X} run {baseDir}/scripts/main.ts 3. 确认 Telegram 发送结果
#!/usr/bin/env bun
/**
* 多平台热词速报 - TypeScript 版
* 采集 HuggingFace、GitHub、Hacker News、Product Hunt、Reddit、YouTube 趋势并推送到 Telegram。
*
* 环境变量:
* TELEGRAM_BOT_TOKEN - Telegram Bot Token(必须)
* TELEGRAM_CHAT_ID - Telegram Chat ID(必须)
* GROK_API_KEY - xAI Grok API Key(可选,用于 AI 分析三大关键趋势)
* PRODUCT_HUNT_TOKEN - Product Hunt API Token(可选)
* YOUTUBE_API_KEY - YouTube Data API Key(可选)
* REDDIT_SUBS - Reddit 子版列表,逗号分隔(可选)
* ITEMS_PER_PLATFORM - 每个平台展示条数,默认 5(可选)
*/
// ─── Config ───────────────────────────────────────────────
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? "";
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
const PRODUCT_HUNT_TOKEN = process.env.PRODUCT_HUNT_TOKEN ?? "";
const GROK_API_KEY = process.env.GROK_API_KEY ?? "";
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY ?? "";
const REDDIT_SUBS = (
process.env.REDDIT_SUBS ??
"MachineLearning,LocalLLaMA,artificial,ChatGPT,singularity"
).split(",");
const ITEMS_PER_PLATFORM = parseInt(
process.env.ITEMS_PER_PLATFORM ?? "5",
10
);
const YOUTUBE_CHANNELS: Record<string, string> = {
"@lexfridman": "Lex Fridman",
"@DwarkeshPatel": "Dwarkesh Podcast",
"@LatentSpaceShow": "Latent Space",
"@a16z": "a16z",
"@OpenAI": "OpenAI",
"@AnthropicAI": "Anthropic",
"@GoogleDeepMind": "Google DeepMind",
"@ycombinator": "Y Combinator",
"@AndrejKarpathy": "Andrej Karpathy",
"@gregisenberg": "Greg Isenberg",
};
const DRY_RUN = process.argv.includes("--dry-run");
// ─── Helpers ────────────────────────────────────────────────
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function fetchText(url: string, init?: RequestInit): Promise<string> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.text();
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
// ─── HuggingFace ──────────────────────────────────────────
interface HFSpace {
id: string;
trending_score: number;
url: string;
}
async function fetchHfSpaces(limit: number): Promise<HFSpace[]> {
try {
const params = new URLSearchParams({
sort: "trendingScore",
direction: "-1",
limit: String(limit),
});
const data = await fetchJson(
`https://huggingface.co/api/spaces?${params}`
);
return data.map((s: any) => ({
id: s.id ?? "",
trending_score: s.trendingScore ?? 0,
url: `https://huggingface.co/spaces/${s.id ?? ""}`,
}));
} catch (e) {
console.log(` ⚠️ HF Spaces: ${e}`);
return [];
}
}
interface HFPaper {
title: string;
summary: string;
upvotes: number;
url: string;
}
async function fetchHfPapers(limit: number): Promise<HFPaper[]> {
try {
const data = await fetchJson("https://huggingface.co/api/daily_papers");
return data.slice(0, limit).map((item: any) => {
const paper = item.paper ?? {};
return {
title: item.title ?? "",
summary: (item.summary ?? "") as string,
upvotes: paper.upvotes ?? 0,
url: `https://huggingface.co/papers/${paper.id ?? ""}`,
};
});
} catch (e) {
console.log(` ⚠️ HF Papers: ${e}`);
return [];
}
}
// ─── GitHub ───────────────────────────────────────────────
interface GitHubRepo {
name: string;
description: string;
today_stars: number;
language: string;
url: string;
}
async function fetchGithub(limit: number): Promise<GitHubRepo[]> {
try {
const html = await fetchText(
"https://github.com/trending?since=daily",
{
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
Accept: "text/html",
},
}
);
const repos: GitHubRepo[] = [];
const blockRe = /<article class="Box-row">(.*?)<\/article>/gs;
let blockMatch: RegExpExecArray | null;
while ((blockMatch = blockRe.exec(html)) !== null) {
const block = blockMatch[1];
// name
const nm = block.match(
/href="\/([^/]+\/[^"]+)"[^>]*>\s*<span[^>]*>([^<]*)<\/span>\s*\/\s*<span[^>]*>([^<]*)<\/span>/
);
let name: string;
if (nm) {
name = `${nm[2].trim()}/${nm[3].trim()}`;
} else {
const nm2 = block.match(/href="\/([^/]+\/[^/"]+)"/);
if (!nm2) continue;
name = nm2[1].trim();
}
// description
const dm = block.match(
/<p class="[^"]*col-9[^"]*"[^>]*>(.*?)<\/p>/s
);
const desc = dm ? dm[1].replace(/<[^>]+>/g, "").trim() : "";
// stars today
const tm = block.match(/([0-9,]+)\s*stars?\s*today/i);
const todayStars = tm ? parseInt(tm[1].replace(/,/g, ""), 10) : 0;
// language
const lm = block.match(
/itemprop="programmingLanguage"[^>]*>([^<]+)<\/span>/
);
const lang = lm ? lm[1].trim() : "";
repos.push({
name,
description: desc.slice(0, 50),
today_stars: todayStars,
language: lang,
url: `https://github.com/${name}`,
});
}
return repos.slice(0, limit);
} catch (e) {
console.log(` ⚠️ GitHub: ${e}`);
return [];
}
}
// ─── Hacker News ──────────────────────────────────────────
interface HNStory {
title: string;
score: number;
comments: number;
hn_url: string;
}
async function fetchHackernews(limit: number): Promise<HNStory[]> {
try {
const ids: number[] = await fetchJson(
"https://hacker-news.firebaseio.com/v0/topstories.json"
);
const topIds = ids.slice(0, limit);
const stories = await Promise.all(
topIds.map(async (sid) => {
const d = await fetchJson(
`https://hacker-news.firebaseio.com/v0/item/${sid}.json`
);
return {
title: d.title ?? "",
score: d.score ?? 0,
comments: d.descendants ?? 0,
hn_url: `https://news.ycombinator.com/item?id=${d.id}`,
} as HNStory;
})
);
stories.sort((a, b) => b.score - a.score);
return stories;
} catch (e) {
console.log(` ⚠️ HackerNews: ${e}`);
return [];
}
}
// ─── Product Hunt ─────────────────────────────────────────
interface PHProduct {
name: string;
tagline: string;
votes: number;
topics: string[];
url: string;
}
async function fetchProducthunt(limit: number): Promise<PHProduct[]> {
if (!PRODUCT_HUNT_TOKEN) return [];
const query = `query { posts(first: ${limit}, order: RANKING) { edges { node {
name tagline votesCount commentsCount url
topics { edges { node { name } } }
} } } }`;
try {
const data = await fetchJson(
"https://api.producthunt.com/v2/api/graphql",
{
method: "POST",
headers: {
Authorization: `Bearer ${PRODUCT_HUNT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
}
);
if (data.errors) return [];
const edges = data?.data?.posts?.edges ?? [];
return edges.map((edge: any) => {
const n = edge.node ?? {};
const topics = (n.topics?.edges ?? [])
.map((t: any) => t?.node?.name)
.filter(Boolean)
.slice(0, 3);
return {
name: n.name ?? "",
tagline: n.tagline ?? "",
votes: n.votesCount ?? 0,
topics,
url: n.url ?? "",
};
});
} catch (e) {
console.log(` ⚠️ ProductHunt: ${e}`);
return [];
}
}
// ─── Reddit ───────────────────────────────────────────────
interface RedditPost {
subreddit: string;
title: string;
score: number;
comments: number;
url: string;
}
async function fetchReddit(
subs: string[],
limitPerSub: number
): Promise<RedditPost[]> {
const results = await Promise.all(
subs.map(async (sub) => {
try {
const data = await fetchJson(
`https://www.reddit.com/r/${sub}/hot.json?limit=${limitPerSub}`,
{ headers: { "User-Agent": "TrendsBulletin/1.0" } }
);
const posts: RedditPost[] = [];
for (const child of data?.data?.children ?? []) {
const p = child.data ?? {};
if (p.stickied) continue;
posts.push({
subreddit: sub,
title: p.title ?? "",
score: p.score ?? 0,
comments: p.num_comments ?? 0,
url: `https://reddit.com${p.permalink ?? ""}`,
});
}
return posts;
} catch {
return [];
}
})
);
const allPosts = results.flat();
allPosts.sort((a, b) => b.score - a.score);
return allPosts;
}
// ─── YouTube ──────────────────────────────────────────────
interface YouTubeVideo {
video_id: string;
title: string;
channel: string;
published_at: string;
url: string;
views: number;
}
async function fetchYoutube(limit: number): Promise<YouTubeVideo[]> {
if (!YOUTUBE_API_KEY) return [];
const base = "https://www.googleapis.com/youtube/v3";
const allVideos: YouTubeVideo[] = [];
for (const [handle, displayName] of Object.entries(YOUTUBE_CHANNELS)) {
try {
const channelParams = new URLSearchParams({
part: "contentDetails",
key: YOUTUBE_API_KEY,
});
if (handle.startsWith("@")) {
channelParams.set("forHandle", handle);
} else {
channelParams.set("id", handle);
}
const cData = await fetchJson(
`${base}/channels?${channelParams}`
);
const items = cData.items ?? [];
if (!items.length) continue;
const uploadsId =
items[0].contentDetails.relatedPlaylists.uploads;
const plParams = new URLSearchParams({
part: "snippet",
playlistId: uploadsId,
maxResults: "2",
key: YOUTUBE_API_KEY,
});
const plData = await fetchJson(
`${base}/playlistItems?${plParams}`
);
const videoIds: string[] = [];
const vids: YouTubeVideo[] = [];
for (const item of plData.items ?? []) {
const sn = item.snippet;
const vid = sn?.resourceId?.videoId;
if (vid) {
videoIds.push(vid);
vids.push({
video_id: vid,
title: sn.title ?? "",
channel: displayName,
published_at: sn.publishedAt ?? "",
url: `https://youtube.com/watch?v=${vid}`,
views: 0,
});
}
}
if (videoIds.length) {
const stParams = new URLSearchParams({
part: "statistics",
id: videoIds.join(","),
key: YOUTUBE_API_KEY,
});
const stData = await fetchJson(
`${base}/videos?${stParams}`
);
const stats: Record<string, number> = {};
for (const i of stData.items ?? []) {
stats[i.id] = parseInt(
i.statistics?.viewCount ?? "0",
10
);
}
for (const v of vids) {
v.views = stats[v.video_id] ?? 0;
}
}
allVideos.push(...vids);
} catch {
continue;
}
}
allVideos.sort(
(a, b) =>
(b.published_at ?? "").localeCompare(a.published_at ?? "")
);
return allVideos.slice(0, limit);
}
// ─── AI 趋势分析 ─────────────────────────────────────────
interface KeyTrend {
title: string;
insight: string;
data: string;
why_important: string;
source_url: string;
}
function compileTrendsContext(
hfSpaces: HFSpace[],
hfPapers: HFPaper[],
github: GitHubRepo[],
hackernews: HNStory[],
producthunt: PHProduct[],
reddit: RedditPost[],
youtube: YouTubeVideo[]
): string {
const sections: string[] = [];
if (hfSpaces.length) {
sections.push(
"【HuggingFace Spaces】\n" +
hfSpaces
.map((s) => `- ${s.id} (热度: ${s.trending_score}) ${s.url}`)
.join("\n")
);
}
if (hfPapers.length) {
sections.push(
"【每日论文】\n" +
hfPapers
.map((p) => `- ${p.title} (点赞: ${p.upvotes}) ${p.url}`)
.join("\n")
);
}
if (github.length) {
sections.push(
"【GitHub Trending】\n" +
github
.map(
(r) =>
`- ${r.name} (今日+${r.today_stars}⭐) ${r.language} ${r.description} ${r.url}`
)
.join("\n")
);
}
if (hackernews.length) {
sections.push(
"【Hacker News】\n" +
hackernews
.map(
(s) =>
`- ${s.title} (分数: ${s.score}, 评论: ${s.comments}) ${s.hn_url}`
)
.join("\n")
);
}
if (producthunt.length) {
sections.push(
"【Product Hunt】\n" +
producthunt
.map((p) => `- ${p.name}: ${p.tagline} (投票: ${p.votes}) ${p.url}`)
.join("\n")
);
}
if (reddit.length) {
sections.push(
"【Reddit】\n" +
reddit
.map(
(p) =>
`- [r/${p.subreddit}] ${p.title} (分数: ${p.score}, 评论: ${p.comments}) ${p.url}`
)
.join("\n")
);
}
if (youtube.length) {
sections.push(
"【YouTube】\n" +
youtube
.map(
(v) =>
`- [${v.channel}] ${v.title} (播放: ${v.views}) ${v.url}`
)
.join("\n")
);
}
return sections.join("\n\n");
}
async function analyzeKeyTrends(context: string): Promise<KeyTrend[]> {
if (!GROK_API_KEY) return [];
try {
console.log("🤖 正在用 AI 分析三大关键趋势...");
const res = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${GROK_API_KEY}`,
},
signal: AbortSignal.timeout(30_000),
body: JSON.stringify({
model: "grok-3-mini-fast",
messages: [
{
role: "system",
content:
"你是一位资深科技趋势分析师。根据多平台数据识别关键趋势,输出 JSON。",
},
{
role: "user",
content: `以下是今天从 6 个平台采集的热门趋势数据。请从中识别出 3 个最值得关注的关键趋势。
要求:
1. 趋势应是跨平台共振或具有重大影响力的话题
2. 每个趋势必须引用数据中的具体项目
3. source_url 必须从数据中选取一条真实链接
4. 所有内容用中文输出
请直接返回 JSON 数组(不要 markdown 代码块),每个元素包含:
- title: 趋势标题(中文,8字以内)
- insight: 核心观点(中文,1-2句话)
- data: 关键数据(中文,引用具体数字或排名)
- why_important: 为什么重要(中文,1-2句话)
- source_url: 最相关的一条原文链接
数据:
${context}`,
},
],
temperature: 0.7,
}),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
const content: string = data.choices?.[0]?.message?.content ?? "";
// 从可能的 markdown 代码块或包裹文字中提取 JSON 数组
const arrayMatch = content.match(/\[[\s\S]*\]/);
const jsonStr = arrayMatch ? arrayMatch[0] : content.trim();
const parsed = JSON.parse(jsonStr);
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, 3).map((t: any) => ({
title: t.title ?? "",
insight: t.insight ?? "",
data: t.data ?? "",
why_important: t.why_important ?? "",
source_url: t.source_url ?? "",
}));
} catch (e) {
console.log(` ⚠️ AI 分析失败: ${e}`);
return [];
}
}
// ─── Telegram ─────────────────────────────────────────────
function buildMessage(
hfSpaces: HFSpace[],
hfPapers: HFPaper[],
github: GitHubRepo[],
hackernews: HNStory[],
producthunt: PHProduct[],
reddit: RedditPost[],
youtube: YouTubeVideo[],
keyTrends: KeyTrend[],
n: number = 5
): string {
const parts: string[] = ["🔥 热词速报", ""];
if (keyTrends.length) {
const emoji = ["1️⃣", "2️⃣", "3️⃣"];
parts.push("━━━ 🎯 三大关键趋势 ━━━");
parts.push("");
keyTrends.forEach((t, i) => {
parts.push(`${emoji[i] ?? `${i + 1}.`} ${t.title}`);
parts.push(`💡 核心观点:${t.insight}`);
parts.push(`📊 关键数据:${t.data}`);
parts.push(`❗ 为什么重要:${t.why_important}`);
parts.push(`🔗 ${t.source_url}`);
parts.push("");
});
}
if (hfSpaces.length) {
parts.push("━━━ HuggingFace Spaces ━━━");
parts.push("");
hfSpaces.slice(0, n).forEach((s, i) => {
parts.push(`${i + 1}. ${s.id} (🔥${s.trending_score})`);
});
parts.push("");
}
if (hfPapers.length) {
parts.push("━━━ 每日论文 ━━━");
parts.push("");
hfPapers.slice(0, n).forEach((p, i) => {
const title =
p.title.length > 40 ? p.title.slice(0, 40) + "..." : p.title;
parts.push(`${i + 1}. ${title} (👍${p.upvotes})`);
if (p.summary) {
const summ = p.summary.length > 60 ? p.summary.slice(0, 60) + "..." : p.summary;
parts.push(` ${summ}`);
}
});
parts.push("");
}
if (hackernews.length) {
parts.push("━━━ Hacker News ━━━");
parts.push("");
hackernews.slice(0, n).forEach((s, i) => {
const title =
s.title.length > 45 ? s.title.slice(0, 45) + "..." : s.title;
parts.push(
`${i + 1}. ${title} (🔥${s.score} | 💬${s.comments})`
);
});
parts.push("");
}
if (producthunt.length) {
parts.push("━━━ Product Hunt ━━━");
parts.push("");
producthunt.slice(0, n).forEach((p, i) => {
const tagline =
p.tagline.length > 50
? p.tagline.slice(0, 50) + "..."
: p.tagline;
parts.push(`${i + 1}. ${p.name} (🔺${p.votes})`);
if (tagline) {
parts.push(` ${tagline}`);
}
if (p.topics.length) {
parts.push(` ${p.topics.join(" | ")}`);
}
});
parts.push("");
}
if (reddit.length) {
parts.push("━━━ Reddit 热帖 ━━━");
parts.push("");
reddit.slice(0, n).forEach((p, i) => {
const title =
p.title.length > 45 ? p.title.slice(0, 45) + "..." : p.title;
parts.push(
`${i + 1}. [r/${p.subreddit}] ${title} (🔺${formatCount(p.score)} | 💬${p.comments})`
);
});
parts.push("");
}
if (youtube.length) {
parts.push("━━━ YouTube 新视频 ━━━");
parts.push("");
youtube.slice(0, n).forEach((v, i) => {
const title =
v.title.length > 40 ? v.title.slice(0, 40) + "..." : v.title;
parts.push(
`${i + 1}. [${v.channel}] ${title} (👁️${formatCount(v.views)})`
);
});
parts.push("");
}
if (github.length) {
parts.push("━━━ GitHub Trending ━━━");
parts.push("");
github.slice(0, n).forEach((r, i) => {
const langTag = r.language ? ` ${r.language}` : "";
parts.push(
`${i + 1}. ${r.name} ⭐+${r.today_stars.toLocaleString()}${langTag}`
);
if (r.description) {
parts.push(` ${r.description}...`);
}
});
parts.push("");
}
return parts.join("\n");
}
async function sendTelegram(message: string): Promise<boolean> {
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`;
// Telegram sendMessage 限制 4096 字符,超长时分段发送
const MAX_LEN = 4096;
const chunks: string[] = [];
if (message.length <= MAX_LEN) {
chunks.push(message);
} else {
let remaining = message;
while (remaining.length > 0) {
if (remaining.length <= MAX_LEN) {
chunks.push(remaining);
break;
}
// 在最大长度内找最后一个换行符进行分割
let splitAt = remaining.lastIndexOf("\n", MAX_LEN);
if (splitAt <= 0) splitAt = MAX_LEN;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).replace(/^\n/, "");
}
}
try {
for (const chunk of chunks) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: TELEGRAM_CHAT_ID,
text: chunk,
}),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
if (!(data.ok ?? false)) return false;
}
return true;
} catch (e) {
console.log(`❌ Telegram 发送失败: ${e}`);
return false;
}
}
// ─── Main ─────────────────────────────────────────────────
async function main() {
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) {
console.log(
"❌ 缺少 TELEGRAM_BOT_TOKEN 或 TELEGRAM_CHAT_ID 环境变量"
);
process.exit(1);
}
const n = ITEMS_PER_PLATFORM;
console.log("📡 正在从 6 个平台采集趋势数据...");
const results = await Promise.allSettled([
fetchHfSpaces(n),
fetchHfPapers(n),
fetchGithub(n),
fetchHackernews(n),
fetchProducthunt(n),
fetchReddit(REDDIT_SUBS, 5),
fetchYoutube(n),
]);
const extract = <T>(r: PromiseSettledResult<T[]>): T[] =>
r.status === "fulfilled" ? r.value : [];
const hfSpaces = extract(results[0]);
const hfPapers = extract(results[1]);
const github = extract(results[2]);
const hackernews = extract(results[3]);
const producthunt = extract(results[4]);
const reddit = extract(results[5]);
const youtube = extract(results[6]);
const counts: Record<string, number> = {
"HuggingFace Spaces": hfSpaces.length,
"HF Papers": hfPapers.length,
GitHub: github.length,
HackerNews: hackernews.length,
ProductHunt: producthunt.length,
Reddit: reddit.length,
YouTube: youtube.length,
};
for (const [name, count] of Object.entries(counts)) {
const status = count > 0 ? "✅" : "⚠️";
console.log(` ${status} ${name}: ${count} 条`);
}
// AI 分析三大关键趋势
const context = compileTrendsContext(
hfSpaces,
hfPapers,
github,
hackernews,
producthunt,
reddit,
youtube
);
const keyTrends = await analyzeKeyTrends(context);
if (keyTrends.length) {
console.log(` ✅ AI 分析: ${keyTrends.length} 条关键趋势`);
}
const message = buildMessage(
hfSpaces,
hfPapers,
github,
hackernews,
producthunt,
reddit,
youtube,
keyTrends,
n
);
if (DRY_RUN) {
console.log("\n🏃 Dry-run 模式,仅打印消息:");
console.log("\n--- 消息内容 ---");
console.log(message);
return;
}
console.log("\n📤 发送热词速报到 Telegram...");
const ok = await sendTelegram(message);
if (ok) {
console.log("✅ 热词速报已发送!");
} else {
console.log("❌ 发送失败");
console.log("\n--- 消息内容 ---");
console.log(message);
process.exit(1);
}
}
main();
{
"name": "trends-bulletin",
"private": true,
"type": "module"
}