// 抓取主流程:遍历订阅源 → 抓取 → 解析 → 去重 → 通知 → 写 KV // 迁自 check-feeds.js 的 resolveFeed + main,作为 CF Cron 的 scheduled handler。 import type { Env } from '../../types'; import { fetchUrl } from './fetch'; import { discoverFeedUrl, tryCommonFeedPaths } from './discover'; import { parseFeedXml, parseJsonFeed, parseCustomJson, isJsonResponse, type Article, type FeedConfig, type ParsedFeed, } from './parse'; import { sendFeishuNotification } from './notify'; import { kvGetJson, kvPutJson } from './util'; const MAX_SEEN = 500; /** 单源抓取超时(原来 30s:一个挂掉的源能把整批拖到 4 分钟以上) */ const FEED_TIMEOUT = 8000; /** 连续失败这么多次就进入退避期,不再每次抓(否则每小时白等 8 秒) */ const FAIL_THRESHOLD = 3; /** 退避时长:6 小时(期间只在其它源都抓完后才可能被跳过) */ const FAIL_BACKOFF_MS = 6 * 3600 * 1000; interface FailRecord { n: number; at: number } /** 抓取 + 解析单个订阅源(含 feed 自动发现)。失败返回 null。 */ async function resolveFeed(env: Env, feedConfig: FeedConfig): Promise { const url = feedConfig.url; const format = feedConfig.format || 'auto'; const forceProxy = feedConfig.proxy === true; let response; try { response = await fetchUrl(env, url, FEED_TIMEOUT, forceProxy); } catch { // 直连 + 代理都失败:不再重复抓同一个地址(原来会白等一个超时), // 直接把常见的 feed 路径猜一遍就放弃。 response = undefined; } if (!response) { const guessedUrl = await tryCommonFeedPaths(env, url); if (!guessedUrl) return null; try { response = await fetchUrl(env, guessedUrl, FEED_TIMEOUT, forceProxy); } catch { return null; } } const { text, contentType } = response; const isJson = isJsonResponse(text, contentType); if (format === 'json' || (format === 'auto' && isJson)) { try { const json = JSON.parse(text); if ( json.version?.includes('jsonfeed.org') || (json.items && Array.isArray(json.items) && !feedConfig.path) ) { return parseJsonFeed(json, url); } return parseCustomJson(json, feedConfig); } catch { return null; } } if ( text.includes(' { // 优先从 KV 读(管理页维护的最新订阅源) const kvFeeds = await kvGetJson<{ feeds: FeedConfig[] }>(env, 'feeds_config', { feeds: [] }); if (kvFeeds.feeds && kvFeeds.feeds.length > 0) { return kvFeeds.feeds.map((f) => (typeof f === 'string' ? { url: f } : f)); } // 回退:FEEDS_URL 远程 JSON if (env.FEEDS_URL) { try { const res = await fetch(env.FEEDS_URL, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' }, }); if (res.ok) { const json = (await res.json()) as any; if (Array.isArray(json)) { return json.map((item: string | FeedConfig) => typeof item === 'string' ? { url: item } : item, ); } if (json.feeds && Array.isArray(json.feeds)) { return json.feeds.map((item: string | FeedConfig) => typeof item === 'string' ? { url: item } : item, ); } } } catch { // 忽略,走空 } } return []; } interface SeenData { lastCheck: string | null; articles: Record; } /** scheduled 入口:每天抓取一次 */ /** * 抓取一批订阅源。 * 免费版 Worker 每次调用限 50 个 subrequest,66 个源全量一把抓会炸, * 所以按 offset/limit 分批轮转(一天两批跑完全部);latest 按 feed 维度合并写入。 */ export interface CronStats { /** 本批计划抓取的源数 */ batch: number; /** 订阅源总数 */ total: number; ok: number; failed: number; /** 本批抓到的文章条数(去重前) */ articles: number; /** 判定为新文章的条数 */ newArticles: number; /** 是否 dry-run(不通知、不写 KV) */ dry: boolean; durationMs: number; /** 因连续失败处于退避期、本次跳过的源数 */ skipped: number; /** 最慢的几个源,用于排查拖后腿的 feed */ slowest: { url: string; ms: number }[]; /** 本次失败的源(最多列 10 个,便于排查) */ failedUrls: string[]; } export async function runCron( env: Env, opts?: { offset?: number; limit?: number; dry?: boolean; rotate?: boolean }, ): Promise { const startedAt = Date.now(); const dry = opts?.dry === true; const rotate = opts?.rotate === true; const offset = Math.max(opts?.offset ?? 0, 0); const batchLimit = Math.max(opts?.limit ?? 0, 0); const timings: { url: string; ms: number }[] = []; let okCount = 0; let failedCount = 0; let articleCount = 0; let skippedCount = 0; const failedUrls: string[] = []; const emptyStats: CronStats = { batch: 0, total: 0, ok: 0, failed: 0, articles: 0, newArticles: 0, dry, durationMs: 0, skipped: 0, slowest: [], failedUrls: [], }; console.log('🔍 开始检查 RSS Feed...'); const allFeeds = await loadFeeds(env); if (allFeeds.length === 0) { console.log('⚠️ 没有可用的订阅源'); return emptyStats; } // 失败退避表:连续失败 >= 阈值的源,在退避期内不再每次白等超时 const failMap = await kvGetJson>(env, 'rss_fail', {}); const nowTs = Date.now(); const inBackoff = (u: string): boolean => { const r = failMap[u]; return !!r && r.n >= FAIL_THRESHOLD && nowTs - r.at < FAIL_BACKOFF_MS; }; const markFail = (u: string): void => { const r = failMap[u]; failMap[u] = { n: (r?.n ?? 0) + 1, at: nowTs }; }; const clearFail = (u: string): void => { if (failMap[u]) delete failMap[u]; }; console.log(`📋 共 ${allFeeds.length} 个订阅源`); for (const u of Object.keys(failMap)) if (!inBackoff(u)) delete failMap[u]; // 退避期满自动重试 // 环形取本批:offset 开始取 limit 个(limit=0 表示全量)。 // rotate=true 时从 KV 里的游标继续(定时任务用,保证每轮都能覆盖到所有源)。 const feeds: FeedConfig[] = []; let cursorAfter = offset; if (batchLimit > 0 && allFeeds.length > batchLimit) { let start = offset % allFeeds.length; if (rotate && !dry) { const saved = await env.RSS_KV.get('rss_cursor'); const n = parseInt(saved || '0', 10); if (Number.isFinite(n)) start = ((n % allFeeds.length) + allFeeds.length) % allFeeds.length; } let examined = 0; let i = start; while (feeds.length < batchLimit && examined < allFeeds.length) { const f = allFeeds[i % allFeeds.length]; i += 1; examined += 1; if (inBackoff(f.url)) { skippedCount += 1; continue; } feeds.push(f); } cursorAfter = i % allFeeds.length; if (rotate && !dry) await env.RSS_KV.put('rss_cursor', String(cursorAfter)); console.log( `📋 本批 ${feeds.length} 个(cursor=${start}${skippedCount ? `,跳过退避中 ${skippedCount} 个` : ''})`, ); } else { feeds.push(...allFeeds); } const seenData = await kvGetJson(env, 'seen_articles', { lastCheck: null, articles: {}, }); const allNewArticles: Article[] = []; const allFetchedArticles: (Article & { siteUrl: string })[] = []; for (const feedConfig of feeds) { const url = feedConfig.url; console.log(`🔍 检查: ${url}`); const t0 = Date.now(); try { const result = await resolveFeed(env, feedConfig); timings.push({ url, ms: Date.now() - t0 }); if (!result) { failedCount++; markFail(url); if (failedUrls.length < 10) failedUrls.push(url); console.log(' ❌ 未找到 Feed'); continue; } okCount++; articleCount += result.articles.length; clearFail(url); const { feedTitle, articles } = result; console.log(` 📰 ${feedTitle || '未知'} - 共 ${articles.length} 篇`); const recentArticles = articles.slice(0, 10); allFetchedArticles.push( ...recentArticles.map((a) => ({ ...a, feedTitle: feedTitle || '未知', siteUrl: url })), ); const newOnes = recentArticles.filter((a) => !seenData.articles[a.link]); if (newOnes.length > 0) { console.log(` 🆕 发现 ${newOnes.length} 篇新文章`); allNewArticles.push(...newOnes); } else { console.log(' ✅ 无新文章'); } for (const a of recentArticles) { seenData.articles[a.link] = a.pubDate; } } catch (err) { timings.push({ url, ms: Date.now() - t0 }); failedCount++; markFail(url); if (failedUrls.length < 10) failedUrls.push(url); console.error(` ❌ 抓取失败: ${(err as Error).message}`); } } // 通知(仅最近 24h 内的) const oneDayAgo = Date.now() - 86400000; const recentNew = allNewArticles.filter( (a) => new Date(a.pubDate).getTime() > oneDayAgo || !seenData.lastCheck, ); if (dry) { console.log(`🧪 dry-run:跳过通知(本应通知 ${recentNew.length} 篇)与 KV 写入`); } else if (recentNew.length > 0) { await sendFeishuNotification(env, recentNew); } else if (allNewArticles.length > 0) { console.log('ℹ️ 发现新文章但超过 24 小时,不推送'); } else { console.log('✅ 所有博客均无新文章'); } // 写 latest 缓存(供 /api/results、/api/articles 读) // 分批模式:本批源的条目替换旧缓存里的同源条目,其他源的保留 const byFeed: Record = {}; for (const a of allFetchedArticles) { const key = a.feedTitle || '未知'; if (!byFeed[key]) byFeed[key] = { articles: [], siteUrl: a.siteUrl || '' }; byFeed[key].articles.push({ title: a.title, link: a.link, pubDate: a.pubDate, author: a.author, feedTitle: key }); } if (!dry) { try { await kvPutJson(env, 'rss_fail', failMap); } catch { /* 失败表写失败不影响主流程 */ } } const durationMs = Date.now() - startedAt; const stats: CronStats = { batch: feeds.length, total: allFeeds.length, ok: okCount, failed: failedCount, articles: articleCount, newArticles: allNewArticles.length, dry, durationMs, skipped: skippedCount, slowest: timings.sort((a, b) => b.ms - a.ms).slice(0, 5), failedUrls, }; if (dry) return stats; const prev = await kvGetJson<{ timestamp: string | null; total: number; feeds: { name: string; siteUrl: string; favicon: string; articles: Article[] }[] }>( env, 'latest', { timestamp: null, total: 0, feeds: [] }, ); const batchUrls = new Set(feeds.map((f) => f.url)); const kept = prev.feeds.filter((f) => !batchUrls.has(f.siteUrl || f.name)); const mergedFeeds = [ ...kept, ...Object.entries(byFeed).map(([name, { articles, siteUrl }]) => ({ name, siteUrl, // 必须写绝对地址:友圈页在 usj.cc 上,相对路径 /api/* 会 404 → 退化成第三方默认头像 favicon: siteUrl ? `${env.PUBLIC_API_BASE || 'https://api.200181.xyz'}/api/favicon?url=${encodeURIComponent(siteUrl)}` : '', articles, })), ]; const mergedTotal = mergedFeeds.reduce((n, f) => n + f.articles.length, 0); await kvPutJson(env, 'latest', { timestamp: new Date().toISOString(), total: mergedTotal, feeds: mergedFeeds, }); // 清理 + 保存去重记录 const entries = Object.entries(seenData.articles); if (entries.length > MAX_SEEN) { entries.sort((a, b) => new Date(b[1]).getTime() - new Date(a[1]).getTime()); seenData.articles = Object.fromEntries(entries.slice(0, MAX_SEEN)); } seenData.lastCheck = new Date().toISOString(); await kvPutJson(env, 'seen_articles', seenData); console.log(`📝 已保存去重记录 (${Object.keys(seenData.articles).length} 条)`); return stats; }