TypeScript + React + GitHub Actions:我是如何打造全自动化 AI 资讯系统的 - 已开源

简介: 这是一个基于 TypeScript + React 构建的全自动化 AI 资讯聚合系统,集成 14+ 平台、70+ RSS 与 52 个公众号,通过 GitHub Actions 每 2 小时自动抓取、智能过滤(关键词+多层规则)、中英双语翻译与结构化输出,解决信息碎片化与焦虑问题。已开源,MIT 协议。

TypeScript + React + GitHub Actions:我是如何打造全自动化 AI 资讯系统的 - 已开源

去年开始,我养成了一个习惯:每天早上打开十几个网站,刷 AI 新闻。

机器之心、量子位、新智元要看看吧?Hacker News、Reddit 的 AI 板块也不能错过。还有 Twitter 上那帮 AI 大佬的动态,OpenAI、Anthropic、Google DeepMind 的官方账号……

刷完一圈,一上午就没了。

更崩溃的是,有时候看到一篇好文章,过几天想找,死活想不起来是在哪个平台看到的。

信息焦虑症,实锤了。

我就想:能不能有个工具,把这些信息源全部聚合起来,自动更新,还能智能过滤出真正有价值的 AI 内容?

于是就有了 AI News Aggregator 这个项目。


一、它到底解决了什么问题?

做这个项目之前,我梳理了一下痛点:

痛点 解决方案
信息碎片化 统一聚合 14+ 平台、70+ RSS、52 公众号
信息噪音大 智能过滤算法精准提取 AI/科技相关内容
语言障碍 自动翻译英文标题为中文,支持双语展示
实时性差 GitHub Actions 每 2 小时自动更新
数据分散 结构化 JSON 输出,方便二次开发

简单说,就三件事:

1. 多源聚合

从 14 个专业平台(AI今日热榜、TechURLs、TopHub、Buzzing 等)+ 70+ 精选 RSS 订阅 + 52 个微信公众号,实时抓取 AI 资讯。

2. 智能过滤

不是所有内容都值得看。通过关键词匹配 + 正则校验,从海量信息中精准提取 AI/科技相关内容,过滤掉电商、娱乐等噪音。

3. 自动更新

GitHub Actions 每 2 小时自动抓取一次,数据永远保持最新。还支持英文标题自动翻译为中文,提供双语展示。

AI News Aggregator 核心价值


二、整体架构:分层设计,职责清晰

先看一张整体架构图:

系统架构图

项目整体分为四层:数据采集层 → 数据处理层 → 数据输出层 → Web 展示层,每层职责清晰,高内聚低耦合。


三、抓取器架构:策略模式 + 模板方法

这是整个项目最核心的部分。我设计了 14 个内置抓取器,每个对应一个平台:

抓取器 平台 技术方案
AiHotFetcher AI今日热榜 Next.js SSR 数据提取
TophubFetcher TopHub HTML 解析 + 编码检测
OpmlRssFetcher OPML RSS rss-parser 解析
WechatRssFetcher 微信公众号 RSSHub 订阅
YouTubeFetcher YouTube 视频信息抓取
TechUrlsFetcher TechURLs HTML 解析
BuzzingFetcher Buzzing HTML 解析
NewsNowFetcher NewsNow HTML 解析
AiBaseFetcher AIbase HTML 解析
AiHubTodayFetcher AI HubToday HTML 解析
BestBlogsFetcher BestBlogs HTML 解析
IrisFetcher Info Flow RSS 解析
ZeliFetcher Zeli HTML 解析
XinzhiyuanFetcher 新智元 RSS 解析

3.1 基类设计:模板方法模式

为了复用通用逻辑,我设计了一个抽象基类 BaseFetcher

export abstract class BaseFetcher implements Fetcher {
   
  abstract siteId: string;
  abstract siteName: string;
  abstract fetch(now: Date): Promise<RawItem[]>;

  // 模板方法:HTML 抓取
  protected async fetchHtml(url: string): Promise<CheerioAPI> {
   
    const html = await fetchText(url);
    return cheerio.load(html);
  }

  // 模板方法:JSON 抓取
  protected async fetchJsonData<T>(url: string): Promise<T> {
   
    return fetchJson<T>(url);
  }

  // 工厂方法:创建标准数据项
  protected createItem(params: {
   ...}): RawItem {
   
    return {
   
      siteId: this.siteId,
      siteName: this.siteName,
      ...params
    };
  }
}

每个抓取器实现统一的 Fetcher 接口:

interface Fetcher {
   
  siteId: string;
  siteName: string;
  fetch(now: Date): Promise<RawItem[]>;
}

这种策略模式的设计,让扩展新数据源变得极其简单——只需新建一个类,实现 fetch 方法,然后注册到工厂函数即可。

抓取器策略模式

3.2 AI今日热榜:Next.js SSR 数据提取

这个比较有意思。AI今日热榜是用 Next.js 构建的,数据不在 HTML 里,而是在 __NEXT_DATA__ 或流式 hydration 的 __next_f.push 中。

怎么提取呢?

export class AiHotFetcher extends BaseFetcher {
   
  siteId = 'aihot';
  siteName = 'AI今日热榜';

  async fetch(now: Date): Promise<RawItem[]> {
   
    const html = await fetchText('https://aihot.today/');

    // 方案1:从 __next_f.push 中提取(流式 hydration)
    const decoded = extractNextFMerged(html);
    let initialData = extractBalancedJson(decoded, 'initialDataMap');

    // 方案2:回退到 __NEXT_DATA__ script(传统 SSR)
    if (!initialData) {
   
      const nextData = extractNextDataPayload(html);
      initialData = nextData?.props?.pageProps?.initialDataMap;
    }

    // 数据转换
    for (const [sourceId, dataItems] of Object.entries(initialData)) {
   
      for (const item of dataItems) {
   
        items.push(this.createItem({
   
          source: sourceName,
          title: item.title_trans || item.title,
          url: item.link,
          publishedAt: parseDate(item.publish_time, now),
        }));
      }
    }

    return items;
  }
}

关键是双重降级策略,确保数据提取的健壮性。

3.3 OPML RSS 解析器:并发控制 + URL 替换

RSS 订阅是最复杂的数据源,需要处理很多边界情况:

// OPML 解析:递归处理嵌套 outline
export function parseOpmlSubscriptions(opmlContent: string): OpmlFeed[] {
   
  const parser = new XMLParser({
   
    ignoreAttributes: false,
    attributeNamePrefix: '@_',
  });
  const doc = parser.parse(opmlContent);

  // 递归处理嵌套 outline
  function processOutline(outline: unknown): void {
   
    const outlines = Array.isArray(outline) ? outline : [outline];
    for (const o of outlines) {
   
      const xmlUrl = o['@_xmlUrl'];
      if (xmlUrl && !seen.has(xmlUrl)) {
   
        feeds.push({
   
          title: o['@_title'] || o['@_text'],
          xmlUrl,
          htmlUrl: o['@_htmlUrl'],
        });
      }
      if (o.outline) processOutline(o.outline); // 递归
    }
  }

  return feeds;
}

// URL 替换与跳过逻辑
function resolveOfficialRssUrl(feedUrl: string) {
   
  // 跳过不支持的源(Telegram/B站/知乎等)
  for (const prefix of CONFIG.rss.skipPrefixes) {
   
    if (feedUrl.startsWith(prefix)) {
   
      return {
    url: null, skipReason: 'no_official_rss' };
    }
  }

  // 替换不稳定的 RSSHub 为官方源
  const replaced = CONFIG.rss.replacements[feedUrl];
  if (replaced) return {
    url: replaced, skipReason: null };

  return {
    url: feedUrl, skipReason: null };
}

// 并发抓取:p-limit 控制并发数为 20
export async function fetchOpmlRss(now, opmlPath, maxFeeds) {
   
  const feeds = parseOpmlSubscriptions(await readFile(opmlPath));
  const limit = pLimit(CONFIG.rss.maxConcurrency);

  const results = await Promise.all(
    resolvedFeeds.map((feed) => 
      limit(() => fetchSingleFeed(feed, now))
    )
  );

  return {
    items, summaryStatus, feedStatuses };
}

四、过滤器模块:四层过滤策略

AI 相关性过滤是最核心的逻辑。不是所有内容都是 AI 相关的,比如 TopHub 上有淘宝热销榜、微博热搜,这些需要过滤掉。

我设计了四层过滤策略

export function isAiRelated(record: ArchiveItem): boolean {
   
  const siteId = record.site_id.toLowerCase();
  const text = `${
     title} ${
     source} ${
     siteName} ${
     url}`.toLowerCase();

  // 规则1:特定站点全部放行
  if (['aibase', 'aihot', 'aihubtoday'].includes(siteId)) {
   
    return true;
  }

  // 规则2:TopHub 需要额外校验来源白名单
  if (siteId === 'tophub') {
   
    if (hasMojibakeNoise(source)) return false;
    if (containsAnyKeyword(source, CONFIG.filter.tophubBlockKeywords)) return false;
    if (!containsAnyKeyword(source, CONFIG.filter.tophubAllowKeywords)) return false;
  }

  // 规则3:关键词匹配
  const hasAi = containsAnyKeyword(text, CONFIG.filter.aiKeywords) ||
                CONFIG.filter.enSignalPattern.test(text);
  const hasTech = containsAnyKeyword(text, CONFIG.filter.techKeywords);

  // 规则4:噪音过滤
  if (containsAnyKeyword(text, CONFIG.filter.commerceNoiseKeywords) && !hasAi) return false;
  if (containsAnyKeyword(text, CONFIG.filter.noiseKeywords) && !hasAi) return false;

  return hasAi || hasTech;
}

过滤策略分层

四层过滤策略

关键词列表是这样定义的:

filter: {
   
  aiKeywords: ['aigc', 'llm', 'gpt', 'claude', 'gemini', 'deepseek', 
               'openai', 'anthropic', 'hugging face', 'transformer', 
               'prompt', 'diffusion', 'agent', '多模态', '大模型', ...],
  techKeywords: ['robot', 'chip', 'semiconductor', 'cuda', 'gpu', 
                 'cloud', 'developer', '开源', '技术', '芯片', ...],
  noiseKeywords: ['娱乐', '明星', '八卦', '足球', '篮球', '彩票', ...],
  commerceNoiseKeywords: ['淘宝', '天猫', '京东', '拼多多', '券后', ...],
}

去重逻辑

同一篇文章可能被多个平台转载,需要根据 title + url 去重:

export function dedupeItemsByTitleUrl(
  items: ArchiveItem[],
  randomPick: boolean = true
): ArchiveItem[] {
   
  const groups = new Map<string, ArchiveItem[]>();

  for (const item of items) {
   
    // 生成去重 Key:title + url
    const key = siteId === 'aihubtoday' 
      ? `url::${
     url}`                    // 特殊处理
      : `${
     title.toLowerCase()}||${
     url}`;

    if (!groups.has(key)) groups.set(key, []);
    groups.get(key)!.push(item);
  }

  const result: ArchiveItem[] = [];
  for (const values of groups.values()) {
   
    if (randomPick) {
   
      // 随机选择(用于 all 模式)
      result.push(values[Math.floor(Math.random() * values.length)]);
    } else {
   
      // 选择最新的(用于 AI 模式)
      result.push(values.reduce(pickNewest));
    }
  }

  return result.sort(byTimeDesc);
}

五、翻译模块:缓存复用 + 增量翻译

英文标题需要翻译成中文,但 Google 翻译 API 有调用限制。

我设计了三级翻译策略

const TRANSLATE_API = 'https://translate.googleapis.com/translate_a/single';

export async function translateToZhCN(text: string): Promise<string | null> {
   
  const params = new URLSearchParams({
   
    client: 'gtx',      // 使用免费端点
    sl: 'auto',         // 自动检测语言
    tl: 'zh-CN',        // 目标语言
    dt: 't',            // 返回翻译
    q: text,
  });

  const response = await fetchJson(`${
     TRANSLATE_API}?${
     params}`);

  // 解析响应:[[["翻译结果", "原文", ...]]]
  const translated = response[0]
    .filter(seg => seg[0])
    .map(seg => String(seg[0]))
    .join('');

  return translated;
}

export async function addBilingualFields(
  itemsAi: ArchiveItem[],
  itemsAll: ArchiveItem[],
  cache: Map<string, string>,
  maxNewTranslations: number
): Promise<{
   ...}> {
   
  let translatedNow = 0;

  const enrich = async (item: ArchiveItem, allowTranslate: boolean) => {
   
    const title = item.title.trim();

    // 中文标题:直接使用
    if (hasCjk(title)) {
   
      return {
    ...item, title_zh: title };
    }

    // 英文标题:尝试翻译
    if (isMostlyEnglish(title)) {
   
      item.title_en = title;

      // 优先使用缓存
      let zhTitle = cache.get(title);

      // 缓存未命中且允许翻译
      if (!zhTitle && allowTranslate && translatedNow < maxNewTranslations) {
   
        zhTitle = await translateToZhCN(title);
        if (zhTitle) {
   
          cache.set(title, zhTitle);
          translatedNow++;
        }
      }

      if (zhTitle) {
   
        item.title_zh = zhTitle;
        item.title_bilingual = `${
     zhTitle} / ${
     title}`;
      }
    }

    return item;
  };

  // AI 条目:允许翻译
  const aiOut = await Promise.all(itemsAi.map(it => enrich(it, true)));

  // 全部条目:仅使用缓存
  const allOut = await Promise.all(itemsAll.map(it => enrich(it, false)));

  return {
    itemsAi: aiOut, itemsAll: allOut, cache };
}

翻译策略

策略 说明
缓存复用 已翻译标题持久化到 title-zh-cache.json
增量翻译 每次最多翻译 80 个新标题
分级处理 AI 条目优先翻译,其他条目仅用缓存

六、那些踩过的坑

6.1 Next.js SSR 数据提取

AI今日热榜是用 Next.js 构建的,数据不在 HTML 里,而是在 __NEXT_DATA__ 或流式 hydration 的 __next_f.push 中。

关键是双重降级策略,确保数据提取的健壮性。

6.2 编码检测与修复

TopHub 这种国内网站,有时候返回的是 GB18030 编码,而不是 UTF-8。直接解析会乱码。

解决方案:

let html = new TextDecoder('utf-8').decode(buffer);
// 检测是否有乱码字符
if (hasGarbledCharacters(html)) {
   
  const gb18030Html = new TextDecoder('gb18030').decode(buffer);
  // 选择损坏字符更少的版本
  if (countGarbledChars(gb18030Html) < countGarbledChars(html)) {
   
    html = gb18030Html;
  }
}

还有 Mojibake 修复——有些标题在传输过程中被错误编码,需要尝试 Latin1 → UTF-8 转码:

function maybeFixMojibake(text: string): string {
   
  // 检测常见乱码特征
  if (!/[Ãâåèæïð]|[\x80-\x9f]/.test(text)) return text;

  // 尝试 Latin1 -> UTF-8 转码
  const bytes = Buffer.from(text, 'latin1');
  const fixed = bytes.toString('utf-8');
  // 如果修复后仍有乱码,返回原文
  return hasGarbledCharacters(fixed) ? text : fixed;
}

6.3 并发控制

同时抓取 70+ RSS 订阅,不加限制的话会被封 IP。

使用 p-limit 控制并发数:

const limit = pLimit(20); // 最多 20 个并发

const results = await Promise.all(
  feeds.map(feed => limit(() => fetchSingleFeed(feed, now)))
);

6.4 HTTP 请求封装:重试 + 超时

export async function fetchWithRetry(url: string, options = {
   }) {
   
  const {
    retries = 3, timeout = 60000 } = options;

  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= retries; attempt++) {
   
    try {
   
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);

      const response = await fetch(url, {
   
        headers: {
   
          'User-Agent': CONFIG.http.userAgent,
          'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        },
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      // 可重试的状态码:429, 500, 502, 503, 504
      if (!response.ok && CONFIG.http.retryStatusCodes.includes(response.status)) {
   
        throw new Error(`HTTP ${
     response.status}`);
      }

      return response;
    } catch (error) {
   
      lastError = error;
      if (attempt < retries) {
   
        // 指数退避:800ms, 1600ms, 2400ms
        await sleep(CONFIG.http.retryDelay * (attempt + 1));
      }
    }
  }

  throw lastError;
}

七、自动化部署:GitHub Actions 全搞定

项目配置了 GitHub Actions,每 2 小时自动执行:

on:
  schedule:
    - cron: "0 */2 * * *"  # 每 2 小时

工作流程:

GitHub Actions 自动化部署流程

全程自动化,完全不用人工干预。


八、前端架构:React Hooks + 智能预加载

React 18 + TypeScript + Vite + Tailwind CSS,一个标准的现代前端技术栈。

核心 Hook: useNewsData

export function useNewsData(): UseNewsDataReturn {
   
  const [data, setData] = useState<NewsData | null>(null);
  const [selectedSite, setSelectedSite] = useState('opmlrss');
  const [selectedSource, setSelectedSource] = useState('all');
  const [searchQuery, setSearchQuery] = useState('');
  const [displayCount, setDisplayCount] = useState(PAGE_SIZE);
  const [timeRange, setTimeRange] = useState<TimeRange>('24h');

  // 智能预加载:加载 24h 数据后自动预加载 7d 数据
  const preloadedDataRef = useRef<{
    [key in TimeRange]?: NewsData }>({
   });

  // 数据获取
  const fetchData = async (range: TimeRange, isPreload = false) => {
   
    if (preloadedDataRef.current[range] && !isPreload) {
   
      setData(preloadedDataRef.current[range]!);
      return;
    }

    const fileName = range === '24h' ? 'latest-24h.json' : 'latest-7d.json';
    const response = await fetch(`${
     basePath}data/${
     fileName}`);
    const json = await response.json();

    preloadedDataRef.current[range] = json;
    if (!isPreload) setData(json);
  };

  // 筛选逻辑
  const filteredItems = useMemo(() => {
   
    let items = data?.items || [];

    // 按站点筛选
    if (selectedSite !== 'all') {
   
      items = items.filter(item => item.site_id === selectedSite);
    }

    // 按来源筛选
    if (selectedSource !== 'all') {
   
      items = items.filter(item => item.source === selectedSource);
    }

    // 搜索
    if (searchQuery.trim()) {
   
      const query = searchQuery.toLowerCase();
      items = items.filter(item => 
        item.title.toLowerCase().includes(query) ||
        item.source.toLowerCase().includes(query) ||
        item.title_zh?.toLowerCase().includes(query)
      );
    }

    // 分页
    return items.slice(0, displayCount);
  }, [data, selectedSite, selectedSource, searchQuery, displayCount]);

  return {
    data, filteredItems, sourceStats, ... };
}

还有一个智能预加载机制:加载 24h 数据后,自动在后台预加载 7d 数据。这样用户切换时间范围时,可以实现"秒开"。


九、扩展指南:想加新数据源怎么办?

只需三步:

1. 创建新的 Fetcher 类

class NewSourceFetcher extends BaseFetcher {
   
  siteId = 'newsource';
  siteName = 'New Source';

  async fetch(now: Date): Promise<RawItem[]> {
   
    const html = await this.fetchHtml('https://newsource.com');
    // ...解析数据
    return items.map(item => this.createItem({
   ...}));
  }
}

2. 导出

export {
    NewSourceFetcher } from './newsource.js';

3. 注册到工厂函数

export function createAllFetchers(): Fetcher[] {
   
  return [
    ...existing,
    new NewSourceFetcher(),
  ];
}

就这么简单。


十、技术亮点总结

技术亮点汇总

架构亮点

亮点 说明
分层设计 数据采集层 → 处理层 → 输出层 → 展示层,职责清晰
策略模式 Fetcher 接口使扩展新数据源极为简单
增量更新 归档数据只更新变化部分,高效可靠
双重降级 数据提取、编码检测等多重降级策略

工程亮点

亮点 说明
全自动化 GitHub Actions 实现全自动更新部署
类型安全 TypeScript 完整类型定义
缓存复用 翻译缓存减少 API 调用
智能预加载 前端预加载实现切换秒开

性能优化

层级 策略 实现 效果
抓取层 并发限制 p-limit(5) / p-limit(20) 避免被封禁
抓取层 请求超时 AbortController 快速失败
抓取层 指数退避 retryDelay * (attempt + 1) 平滑重试
处理层 增量合并 Map O(1) 查找
处理层 翻译缓存 title-zh-cache.json 减少 API 调用
处理层 翻译限流 maxNewTranslations: 80 控制耗时
前端 虚拟滚动 displayCount + loadMore 减少初始渲染
前端 useMemo 筛选结果缓存 避免重复计算
前端 智能预加载 加载 24h 后预加载 7d 切换秒开

用户体验亮点

亮点 说明
响应式设计 支持移动端
暗色模式 支持浅色/深色主题切换
多维度筛选 按平台、来源、关键词筛选
收藏/历史 本地持久化收藏和阅读历史
双语展示 英文标题自动翻译为中文

写在最后

这个项目解决了我自己的痛点,也希望能帮到同样有信息焦虑的朋友。

技术栈

  • 后端:TypeScript + Cheerio + rss-parser + p-limit
  • 前端:React 18 + Vite + Tailwind CSS
  • 部署:GitHub Actions + GitHub Pages

核心价值

  • 多源聚合,一网打尽
  • 智能过滤,精准推送
  • 自动更新,省心省力
  • 双语支持,无障碍阅读

如果你也想搭建自己的资讯聚合系统,欢迎参考这个项目。代码开源,MIT 协议,随便折腾。 https://github.com/SuYxh/ai-news-aggregator

相关文章
|
2天前
|
人工智能 JSON 机器人
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
本文带你零成本玩转OpenClaw:学生认证白嫖6个月阿里云服务器,手把手配置飞书机器人、接入免费/高性价比AI模型(NVIDIA/通义),并打造微信公众号“全自动分身”——实时抓热榜、AI选题拆解、一键发布草稿,5分钟完成热点→文章全流程!
10354 42
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
|
22天前
|
人工智能 JavaScript Ubuntu
5分钟上手龙虾AI!OpenClaw部署(阿里云+本地)+ 免费多模型配置保姆级教程(MiniMax、Claude、阿里云百炼)
OpenClaw(昵称“龙虾AI”)作为2026年热门的开源个人AI助手,由PSPDFKit创始人Peter Steinberger开发,核心优势在于“真正执行任务”——不仅能聊天互动,还能自动处理邮件、管理日程、订机票、写代码等,且所有数据本地处理,隐私完全可控。它支持接入MiniMax、Claude、GPT等多类大模型,兼容微信、Telegram、飞书等主流聊天工具,搭配100+可扩展技能,成为兼顾实用性与隐私性的AI工具首选。
23414 121
|
8天前
|
人工智能 JavaScript API
解放双手!OpenClaw Agent Browser全攻略(阿里云+本地部署+免费API+网页自动化场景落地)
“让AI聊聊天、写代码不难,难的是让它自己打开网页、填表单、查数据”——2026年,无数OpenClaw用户被这个痛点困扰。参考文章直击核心:当AI只能“纸上谈兵”,无法实际操控浏览器,就永远成不了真正的“数字员工”。而Agent Browser技能的出现,彻底打破了这一壁垒——它给OpenClaw装上“上网的手和眼睛”,让AI能像真人一样打开网页、点击按钮、填写表单、提取数据,24小时不间断完成网页自动化任务。
2060 5

热门文章

最新文章