☰
Codex 100个真实案例 - 用AI做RSS阅读器(聚合订阅+AI摘要)
2026/10/17 5:01:38 网站建设 项目流程

1. 从零搭一个 RSS 阅读器,为什么我建议你先跑通「抓取 + 摘要」这条链路

RSS 阅读器这个需求,看起来简单,真正动手才会发现坑集中在两处:一是订阅源格式五花八门,RSS 2.0、Atom、带命名空间的媒体扩展混在一起;二是抓回来几百篇文章后,人根本读不完,没有摘要等于白抓。所以这个案例我把它拆成两条主线:Node.js 后端负责抓取、解析、去重、入库,Vue3 前端负责订阅流展示和阅读交互,中间用一层 AI 摘要把「读不完」变成「扫一眼」。

Codex 在这类项目里的价值,不是帮你写一个函数,而是帮你把「项目结构 + 依赖 + 关键服务 + 定时任务」一次性铺出来。你只需要把业务规则说清楚,比如「根据 guid 去重」「距离上次抓取超过间隔才抓」「最多同时抓 5 个源」,它生成的代码基本能直接跑。我实测下来,最容易翻车的地方不是代码逻辑,而是订阅源地址写错、数据库字符集没设 utf8mb4 导致中文乱码、以及 AI 接口的 Base URL 和 Key 没配对。

这篇适合谁:会用 Node.js 和 Vue3 基础语法、想做一个自己用的信息聚合工具、并且希望把大模型摘要接进来的开发者。全文按「项目初始化 → 数据库与抓取服务 → 全文搜索 → AI 摘要 → 定时任务与 OPML → 排障」的顺序走,每一步都给可复制的配置和代码片段,你跟着敲就能在本地跑起来。

先说清楚最终形态:左侧是订阅源侧边栏,按分类分组、显示未读数;中间是文章列表;点开文章能看到正文和一张 AI 摘要卡片;顶部有搜索框,支持中文全文检索;后台每 5 分钟自动抓取一次,新文章自动生成摘要。整套东西跑在本地,前端 5173,后端 3001。

2. 前置准备:Codex 环境与 TaoToken 接入配置

在写业务代码之前,先把两件事定下来:Codex 的调用环境,以及大模型 API 的接入方式。很多人卡在第一步不是因为不会写代码,而是 Key 和 Base URL 没配好,请求一直 401。

TaoToken 在这里的角色是统一的大模型接入层。它的 API 地址是https://taotoken.net/api,兼容 OpenAI 风格的/v1/chat/completions接口,所以你在 Node.js 里用 axios 直接 POST 就行,不需要额外 SDK。模型 ID 按你实际要用的填,比如做摘要这种任务,选一个中文能力稳定的对话模型即可。

配置分三件套,缺一不可:Base URL、API Key、Model ID。我建议直接写进后端的.env,不要硬编码在 service 里,方便后面换模型。

# server/.env PORT=3001 CLIENT_URL=http://localhost:5173 # 数据库 DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD=your_password DB_NAME=rss_reader # 大模型接入(TaoToken) LLM_BASE_URL=https://taotoken.net/api LLM_API_KEY=sk-你的Key LLM_MODEL=你的模型ID LLM_TIMEOUT=30000

对应的读取代码用一个独立 config 文件收口,避免散落各处:

// server/config/llm.js require('dotenv').config() module.exports = { baseUrl: process.env.LLM_BASE_URL || 'https://taotoken.net/api', apiKey: process.env.LLM_API_KEY, model: process.env.LLM_MODEL, timeout: Number(process.env.LLM_TIMEOUT || 30000), maxTokens: 500 }

这里有个细节要注意:baseUrl末尾不要带/v1,拼接时统一在请求路径里补/v1/chat/completions,否则容易出现双斜杠或者路径重复。Key 的获取在控制台的 API Keys 页面,创建后只显示一次,记得先存到密码管理器。如果你还没决定用哪个模型,可以先去模型对话页面手动试几句中文摘要,确认输出风格符合预期再写进.env。

环境检查清单,跑之前逐条确认:

node -v # v20 及以上 pnpm -v # 9 及以上 codex --version # 最新版 mysql --version # 8.0 及以上

数据库这块,MySQL 8.0 是硬要求,因为后面全文搜索要用到WITH PARSER ngram,低版本不支持中文分词。字符集统一utf8mb4,否则抓回来的 emoji 和部分中文会变成问号。

3. 可复制配置:项目结构、数据库表与抓取服务

这一节是全文的核心,配置和代码都给全,你复制过去改改路径就能用。

3.1 项目结构与依赖

前端用 Vite 创建 Vue3 + TypeScript 项目,后端单独一个server目录。整体结构如下:

rss-reader/ ├── rss-reader-app/ # Vue3 前端 │ ├── src/ │ │ ├── api/ # 接口封装 │ │ ├── components/ # FeedList / ArticleList / AiSummary │ │ ├── stores/ # Pinia │ │ ├── views/ # AllArticles / FeedView / SearchView │ │ ├── router/ │ │ └── types/ │ └── vite.config.ts └── server/ # Node.js 后端 ├── config/ # database.js / llm.js ├── services/ # feedService / searchService / aiSummaryService ├── routes/ ├── cron/ # feedFetcher.js └── app.js

前端依赖安装:

pnpm create vite rss-reader-app --template vue-ts cd rss-reader-app pnpm add element-plus @element-plus/icons-vue pinia vue-router axios dayjs pnpm add -D @types/node sass

后端依赖:

mkdir server && cd server npm init -y npm add express cors mysql2 rss-parser fast-xml-parser node-cron axios dotenv

rss-parser负责解析 RSS/Atom,fast-xml-parser负责 OPML 的导入导出,node-cron负责定时抓取,mysql2用连接池。

3.2 数据库表结构

三张核心表:分类、订阅源、文章。文章表上建全文索引,用 ngram 分词器支持中文。

CREATE DATABASE IF NOT EXISTS rss_reader DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; USE rss_reader; CREATE TABLE categories ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME DEFAULT NULL, UNIQUE KEY uk_name (name) ) COMMENT '分类表'; CREATE TABLE feeds ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, feed_url VARCHAR(500) NOT NULL, site_url VARCHAR(500) DEFAULT '', icon_url VARCHAR(500) DEFAULT '', description TEXT, category_id INT UNSIGNED DEFAULT NULL, fetch_interval INT NOT NULL DEFAULT 30, last_fetch_time DATETIME DEFAULT NULL, status ENUM('active','error','paused') NOT NULL DEFAULT 'active', error_message VARCHAR(500) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME DEFAULT NULL, UNIQUE KEY uk_feed_url (feed_url), INDEX idx_category (category_id), INDEX idx_status (status) ) COMMENT '订阅源表'; CREATE TABLE articles ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, feed_id INT UNSIGNED NOT NULL, title VARCHAR(500) NOT NULL, link VARCHAR(1000) NOT NULL, author VARCHAR(100) DEFAULT '', published_at DATETIME DEFAULT NULL, content MEDIUMTEXT, summary TEXT, ai_summary TEXT DEFAULT NULL, thumbnail VARCHAR(500) DEFAULT NULL, guid VARCHAR(500) NOT NULL, is_read TINYINT(1) NOT NULL DEFAULT 0, is_favorite TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME DEFAULT NULL, UNIQUE KEY uk_guid (feed_id, guid), INDEX idx_feed (feed_id), INDEX idx_published (published_at), FULLTEXT KEY ft_search (title, content) WITH PARSER ngram ) COMMENT '文章表';

uk_guid (feed_id, guid)这个联合唯一键是去重的关键,配合INSERT IGNORE就能做到重复文章自动跳过。

3.3 抓取与解析服务

feedService负责添加订阅源、解析 Feed、去重入库。核心逻辑是:添加时先解析一次拿到标题和图标,然后立刻抓取文章;定时任务里则只抓取「距离上次抓取超过间隔」的源。

// server/services/feedService.js const Parser = require('rss-parser') const db = require('../config/database') const parser = new Parser({ timeout: 15000, headers: { 'User-Agent': 'RSSReader/1.0 (NodeJS Aggregator)', Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' }, customFields: { item: [ ['media:content', 'mediaContent'], ['media:thumbnail', 'mediaThumbnail'] ] } }) class FeedService { async addFeed(feedUrl, categoryId = null) { const [existing] = await db.query( 'SELECT id FROM feeds WHERE feed_url = ? AND deleted_at IS NULL', [feedUrl] ) if (existing.length > 0) throw new Error('该订阅源已添加过了') let feed try { feed = await parser.parseURL(feedUrl) } catch (err) { throw new Error(`无法解析 RSS 地址: ${err.message}`) } const siteUrl = feed.link || '' let iconUrl = '' if (siteUrl) { try { iconUrl = `https://www.google.com/s2/favicons?domain=${new URL(siteUrl).hostname}&sz=64` } catch (_) { iconUrl = '' } } const [result] = await db.query( `INSERT INTO feeds (title, feed_url, site_url, icon_url, description, category_id, last_fetch_time) VALUES (?, ?, ?, ?, ?, ?, NOW())`, [feed.title || feedUrl, feedUrl, siteUrl, iconUrl, feed.description || '', categoryId] ) const articleCount = await this.fetchArticles(result.insertId, feed) return { id: result.insertId, title: feed.title, articleCount } } async fetchArticles(feedId, parsedFeed = null) { const [feeds] = await db.query( 'SELECT * FROM feeds WHERE id = ? AND deleted_at IS NULL', [feedId] ) if (feeds.length === 0) return 0 const feedInfo = feeds[0] if (!parsedFeed) { try { parsedFeed = await parser.parseURL(feedInfo.feed_url) } catch (err) { await db.query( `UPDATE feeds SET status='error', error_message=?, last_fetch_time=NOW() WHERE id=?`, [err.message, feedId] ) return 0 } } let newCount = 0 for (const item of parsedFeed.items || []) { const guid = item.guid || item.id || item.link || '' if (!guid) continue const [result] = await db.query( `INSERT IGNORE INTO articles (feed_id, title, link, author, published_at, content, summary, thumbnail, guid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ feedId, item.title || '无标题', item.link || '', item.creator || item.author || '', item.pubDate ? new Date(item.pubDate) : new Date(), item['content:encoded'] || item.content || '', item.contentSnippet || item.summary || '', this._extractThumbnail(item), guid ] ) if (result.affectedRows > 0) newCount++ } await db.query( `UPDATE feeds SET status='active', error_message=NULL, last_fetch_time=NOW() WHERE id=?`, [feedId] ) return newCount } _extractThumbnail(item) { if (item.mediaThumbnail?.$.url) return item.mediaThumbnail.$.url if (item.mediaContent?.$.url) return item.mediaContent.$.url if (item.enclosure?.url && item.enclosure.type?.startsWith('image')) { return item.enclosure.url } const content = item['content:encoded'] || item.content || '' const match = content.match(/<img[^>]+src=["']([^"']+)["']/i) return match ? match[1] : null } } module.exports = new FeedService()

数据库连接池配置:

// server/config/database.js const mysql = require('mysql2/promise') require('dotenv').config() const pool = mysql.createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT || 3306), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, charset: 'utf8mb4_unicode_ci' }) module.exports = pool

3.4 前端订阅源侧边栏

侧边栏按分类分组,显示未读数,底部放「添加订阅」和「导入 OPML」两个按钮。核心是groupedFeeds这个计算属性,把扁平列表按categoryName聚合。

<!-- src/components/FeedList.vue --> <template> <div class="feed-sidebar"> <div class="quick-links"> <div class="quick-item" :class="{ active: route.path === '/all' }" @click="router.push('/all')"> <el-icon><Document /></el-icon><span>全部文章</span> <el-badge :value="totalUnread" :max="999" v-if="totalUnread > 0" /> </div> <div class="quick-item" :class="{ active: route.path === '/favorites' }" @click="router.push('/favorites')"> <el-icon><Star /></el-icon><span>收藏夹</span> </div> <div class="quick-item" :class="{ active: route.path === '/search' }" @click="router.push('/search')"> <el-icon><Search /></el-icon><span>搜索</span> </div> </div> <div class="feed-groups" v-loading="loading"> <div v-for="group in groupedFeeds" :key="group.category || 'uncategorized'" class="feed-group"> <div class="group-header" @click="toggleGroup(group.category)"> <el-icon> <ArrowRight v-if="collapsed.has(group.category)" /> <ArrowDown v-else /> </el-icon> <span>{{ group.category || '未分类' }}</span> <span class="group-count">{{ group.unreadCount }}</span> </div> <div v-show="!collapsed.has(group.category)"> <div v-for="feed in group.feeds" :key="feed.id" class="feed-item" :class="{ active: activeFeedId === feed.id }" @click="selectFeed(feed)" > <img :src="feed.iconUrl || defaultIcon" class="feed-icon" @error="onIconError" /> <span class="feed-name" :title="feed.title">{{ feed.title }}</span> <el-badge :value="feed.unreadCount" :max="99" v-if="feed.unreadCount > 0" /> </div> </div> </div> </div> <div class="sidebar-footer"> <el-button type="primary" @click="showAdd = true">添加订阅</el-button> <el-button @click="showImport = true">导入</el-button> </div> </div> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { Document, Star, Search, ArrowRight, ArrowDown } from '@element-plus/icons-vue' import { getFeedList } from '@/api/feed' import type { Feed } from '@/types' const router = useRouter() const route = useRoute() const defaultIcon = '/icons/rss-default.svg' const feeds = ref<Feed[]>([]) const loading = ref(false) const collapsed = ref(new Set<string>()) const activeFeedId = ref<number | null>(null) const showAdd = ref(false) const showImport = ref(false) const totalUnread = computed(() => feeds.value.reduce((sum, f) => sum + (f.unreadCount || 0), 0) ) const groupedFeeds = computed(() => { const map: Record<string, { category: string; feeds: Feed[]; unreadCount: number }> = {} for (const feed of feeds.value) { const key = feed.categoryName || '' if (!map[key]) map[key] = { category: key, feeds: [], unreadCount: 0 } map[key].feeds.push(feed) map[key].unreadCount += feed.unreadCount || 0 } return Object.values(map) }) function toggleGroup(category: string) { collapsed.value.has(category) ? collapsed.value.delete(category) : collapsed.value.add(category) } function selectFeed(feed: Feed) { activeFeedId.value = feed.id router.push(`/feed/${feed.id}`) } function onIconError(e: Event) { (e.target as HTMLImageElement).src = defaultIcon } async function loadFeeds() { loading.value = true try { feeds.value = await getFeedList() } finally { loading.value = false } } onMounted(loadFeeds) </script>

4. 验证请求:本地启动、订阅接入与摘要输出

配置写完之后,必须一步步验证,不要一次性全跑起来再排查。

4.1 启动后端与数据库连通性

先确认数据库能连上,再启动 Express。后端入口:

// server/app.js require('dotenv').config() const express = require('express') const cors = require('cors') const { startFeedFetchJob } = require('./cron/feedFetcher') const app = express() const PORT = process.env.PORT || 3001 app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' })) app.use(express.json({ limit: '10mb' })) app.use('/api/feeds', require('./routes/feed')) app.use('/api/articles', require('./routes/article')) app.use('/api/search', require('./routes/search')) app.use('/api/opml', require('./routes/opml')) app.get('/api/health', (_req, res) => { res.json({ code: 200, message: 'RSS Reader API is running' }) }) app.use((err, _req, res, _next) => { console.error('服务端错误:', err) res.status(500).json({ code: 500, message: err.message }) }) app.listen(PORT, () => { console.log(`RSS 阅读器后端已启动: http://localhost:${PORT}`) startFeedFetchJob() })

启动后先打健康检查:

curl http://localhost:3001/api/health # {"code":200,"message":"RSS Reader API is running"}

如果这里报ECONNREFUSED,说明 MySQL 没启动或者端口不对;如果报ER_ACCESS_DENIED_ERROR,是账号密码问题;如果报ER_BAD_DB_ERROR,是数据库名写错或者没建库。

4.2 添加订阅源并验证抓取

用 curl 直接测添加接口,比点前端更快定位问题:

curl -X POST http://localhost:3001/api/feeds \ -H "Content-Type: application/json" \ -d '{"feedUrl":"https://example.com/feed.xml","categoryId":null}'

成功返回类似:

{ "code": 200, "data": { "id": 1, "title": "示例博客", "articleCount": 20 } }

articleCount是本次新入库的文章数。如果返回 0,可能是这个源本身没有新文章,或者 guid 全部重复。去数据库确认:

SELECT COUNT(*) FROM articles WHERE feed_id = 1; SELECT title, published_at, ai_summary IS NOT NULL AS has_ai FROM articles ORDER BY published_at DESC LIMIT 5;

4.3 验证 AI 摘要输出

摘要服务单独测,不要等定时任务触发。先手动调一次:

curl -X POST http://localhost:3001/api/articles/1/summary

后端aiSummaryService的核心是拼 prompt 和调接口:

// server/services/aiSummaryService.js const axios = require('axios') const db = require('../config/database') const llm = require('../config/llm') class AiSummaryService { async generateSummary(articleId) { const [rows] = await db.query( `SELECT id, title, content, summary FROM articles WHERE id = ? AND ai_summary IS NULL AND deleted_at IS NULL`, [articleId] ) if (rows.length === 0) throw new Error('文章不存在或已有摘要') const article = rows[0] const plain = this._stripHtml(article.content || article.summary || '').slice(0, 3000) const prompt = `请为以下文章生成一段简洁的中文摘要,用3句话概括核心内容。 只输出摘要内容,不要加任何前缀说明。 文章标题:${article.title} 文章内容: ${plain}` const aiSummary = await this._callLLM(prompt) await db.query('UPDATE articles SET ai_summary = ? WHERE id = ?', [aiSummary, articleId]) return aiSummary } async _callLLM(prompt, retries = 2) { for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await axios.post( `${llm.baseUrl}/v1/chat/completions`, { model: llm.model, messages: [ { role: 'system', content: '你是一个专业的文章摘要助手,擅长用简洁语言概括要点。' }, { role: 'user', content: prompt } ], max_tokens: llm.maxTokens, temperature: 0.3 }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${llm.apiKey}` }, timeout: llm.timeout } ) const text = res.data?.choices?.[0]?.message?.content if (!text) throw new Error('大模型返回空结果') return text.trim() } catch (err) { if (attempt < retries) { await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000)) } else { throw new Error(`大模型调用失败: ${err.message}`) } } } } _stripHtml(html) { return html .replace(/<script[\s\S]*?<\/script>/gi, '') .replace(/<style[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/&nbsp;/g, ' ') .replace(/\s+/g, ' ') .trim() } } module.exports = new AiSummaryService()

成功时返回摘要文本,数据库里ai_summary字段被写入。如果返回 401,检查LLM_API_KEY是否带上了Bearer前缀、Key 是否过期;如果返回 404,检查baseUrl拼接后是不是https://taotoken.net/api/v1/chat/completions,多一个或少一个/v1都会 404。

4.4 前端联调

前端启动:

cd rss-reader-app pnpm dev

打开http://localhost:5173,左侧应该能看到刚添加的订阅源,点进去能看到文章列表。点开一篇文章,如果ai_summary为空,会显示「生成 AI 摘要」按钮,点击后调接口,成功后卡片平滑展开。这一步验证的是前后端字段是否对齐——前端读aiSummary,后端返回的 JSON 字段名必须一致,否则摘要生成了但页面不显示。

5. 本篇常见错排查:401、local proxy failed 与 reading choices

这一节按真实报错来,遇到直接对号入座。

401 Unauthorized / invalid api key:最常见。原因有三种:Key 没填、Key 填错、请求头格式不对。检查.env里LLM_API_KEY是否以sk-开头,请求头必须是Authorization: Bearer sk-xxx,中间一个空格。另外确认你用的是 TaoToken 控制台里创建的 Key,而不是别处的。

local proxy failed / connect ECONNREFUSED 127.0.0.1:7890:这个报错说明你的请求被本地某个代理拦截了。检查系统环境变量HTTP_PROXY、HTTPS_PROXY是否被设置,Node.js 的 axios 默认会读这两个变量。在.env里显式清空,或者在启动命令前加HTTP_PROXY= HTTPS_PROXY= node app.js。这类问题在本地开发环境很常见,跟代码无关。

Cannot read properties of undefined (reading 'choices'):说明res.data结构和你预期的不一样。要么是接口返回了错误对象(比如{ error: { message: ... } }),要么是模型 ID 写错导致返回体没有choices。打印完整响应体定位:

console.log('LLM raw response:', JSON.stringify(res.data).slice(0, 500))

如果返回体里是{"error":{"message":"model not found"}},那就是LLM_MODEL填错了,去模型列表里核对准确的模型 ID。

OAuth / token expired:如果你用的是需要 OAuth 的接入方式,token 有有效期,过期后会返回 401 或 403。重新走一遍授权流程拿新 token,或者改用 API Key 方式。Codex 的auth.json里如果存的是过期凭证,也会出现类似报错,删掉重新登录即可。

中文搜索搜不到结果:检查全文索引是否用了 ngram 分词器。SHOW CREATE TABLE articles;看ft_search后面有没有WITH PARSER ngram。没有的话,中文会被当成一个整词,搜「人工智能」匹配不到「人工智能技术」。重建索引:

ALTER TABLE articles DROP INDEX ft_search; ALTER TABLE articles ADD FULLTEXT KEY ft_search (title, content) WITH PARSER ngram;

订阅源添加成功但文章数为 0:先确认这个源本身有没有内容,用浏览器打开 feed 地址看看。如果源正常但抓不到,可能是rss-parser的customFields没覆盖到该源的命名空间,打印parsedFeed.items[0]看字段名,再补进customFields。

前端摘要不显示但数据库有值:字段名大小写问题。MySQL 返回的是ai_summary,前端类型定义里如果写的是aiSummary,需要在 API 层做一次映射,或者后端返回时统一转成驼峰。

6. 定时任务、OPML 与后续接入建议

定时抓取用node-cron,每 5 分钟跑一次,只抓「距离上次抓取超过fetch_interval」的源,并且限制并发数,避免同时打太多请求把对方服务器打挂。

// server/cron/feedFetcher.js const cron = require('node-cron') const db = require('../config/database') const feedService = require('../services/feedService') const aiSummaryService = require('../services/aiSummaryService') const MAX_CONCURRENT = 5 function startFeedFetchJob() { cron.schedule('*/5 * * * *', async () => { try { const [feeds] = await db.query( `SELECT id, title FROM feeds WHERE status='active' AND deleted_at IS NULL AND (last_fetch_time IS NULL OR TIMESTAMPDIFF(MINUTE, last_fetch_time, NOW()) >= fetch_interval) ORDER BY last_fetch_time ASC` ) if (feeds.length === 0) return let totalNew = 0 for (let i = 0; i < feeds.length; i += MAX_CONCURRENT) { const batch = feeds.slice(i, i + MAX_CONCURRENT) const results = await Promise.allSettled( batch.map(f => feedService.fetchArticles(f.id)) ) for (const r of results) { if (r.status === 'fulfilled') totalNew += r.value } } if (totalNew > 0) { await aiSummaryService.batchGenerateSummaries(totalNew) } } catch (err) { console.error('定时抓取失败:', err) } }) console.log('订阅源定时抓取任务已启动(每5分钟)') } module.exports = { startFeedFetchJob }

批量摘要生成要控制并发,最多同时 3 个请求,失败重试用指数退避。这部分逻辑和单篇生成共用_callLLM,只是外面套一层队列。

OPML 导入导出用fast-xml-parser,导入时递归解析outline节点,遇到xmlUrl属性就是订阅源,遇到嵌套outline就是分类文件夹。导出时反向构建,兼容 Feedly、Inoreader 的格式。导入过程中如果某个源解析失败,不要中断整个流程,记录失败数继续处理下一个。

如果你打算长期跑这个阅读器,建议把 AI 摘要做成「按需生成 + 后台补全」双模式:用户点开文章时如果没摘要,立即生成;后台定时任务则批量补全历史文章。这样既保证阅读体验,又不会在抓取时阻塞太久。

最后说一个我踩过的坑:fetch_interval默认 30 分钟,但有些源更新频率很低,30 分钟抓一次纯属浪费请求。可以在添加订阅源时根据源的历史更新频率动态调整,或者统一设成 60 分钟,配合手动刷新按钮。定时任务本身不复杂,复杂的是错误处理——某个源挂了不能影响其他源,所以Promise.allSettled比Promise.all更合适。

整套跑通之后,你得到的不只是一个 RSS 阅读器,而是一条「抓取 → 清洗 → 摘要 → 检索」的完整链路。这条链路换个数据源就能复用到别处,比如抓取行业资讯做日报,或者聚合技术博客做知识库。Codex 帮你省掉的是搭骨架的时间,真正决定好不好用的,还是你对业务规则的定义是否清晰。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询